1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Http;
4:
5: use Salient\Contract\Core\Pipeline\EntityPipelineInterface;
6: use Salient\Contract\Core\Pipeline\PipelineInterface;
7: use Salient\Contract\Core\Pipeline\StreamPipelineInterface;
8: use Salient\Contract\Core\ArrayMapperFlag;
9: use Salient\Contract\Core\Buildable;
10: use Salient\Contract\Core\ListConformity;
11: use Salient\Contract\Core\ProviderContextInterface;
12: use Salient\Contract\Curler\Exception\HttpErrorExceptionInterface;
13: use Salient\Contract\Curler\CurlerInterface;
14: use Salient\Contract\Curler\CurlerPagerInterface;
15: use Salient\Contract\Http\HttpHeadersInterface;
16: use Salient\Contract\Http\HttpRequestMethod;
17: use Salient\Contract\Sync\FilterPolicy;
18: use Salient\Contract\Sync\SyncContextInterface;
19: use Salient\Contract\Sync\SyncEntityInterface;
20: use Salient\Contract\Sync\SyncEntitySource;
21: use Salient\Contract\Sync\SyncOperation as OP;
22: use Salient\Core\Concern\HasBuilder;
23: use Salient\Core\Concern\HasMutator;
24: use Salient\Core\Pipeline;
25: use Salient\Sync\Exception\SyncEntityNotFoundException;
26: use Salient\Sync\Exception\SyncInvalidContextException;
27: use Salient\Sync\Exception\SyncInvalidEntitySourceException;
28: use Salient\Sync\Exception\SyncOperationNotImplementedException;
29: use Salient\Sync\Support\SyncIntrospector;
30: use Salient\Sync\Support\SyncPipelineArgument;
31: use Salient\Sync\AbstractSyncDefinition;
32: use Salient\Utility\Arr;
33: use Salient\Utility\Env;
34: use Salient\Utility\Regex;
35: use Salient\Utility\Str;
36: use Closure;
37: use LogicException;
38: use UnexpectedValueException;
39:
40: /**
41: * Generates closures that use an HttpSyncProvider to perform sync operations on
42: * an entity
43: *
44: * Override {@see HttpSyncProvider::buildHttpDefinition()} to service HTTP
45: * endpoints declaratively via {@see HttpSyncDefinition} objects.
46: *
47: * If more than one implementation of a sync operation is available for an
48: * entity, the order of precedence is as follows:
49: *
50: * 1. The callback in {@see AbstractSyncDefinition::$Overrides} for the
51: * operation
52: * 2. The provider method declared for the operation, e.g.
53: * `Provider::getFaculties()` or `Provider::createUser()`
54: * 3. The closure returned by {@see AbstractSyncDefinition::getClosure()} for
55: * the operation
56: *
57: * If no implementations are found, {@see SyncOperationNotImplementedException}
58: * is thrown.
59: *
60: * @template TEntity of SyncEntityInterface
61: * @template TProvider of HttpSyncProvider
62: *
63: * @phpstan-type OverrideClosure (Closure(static, OP::*, SyncContextInterface, int|string|null, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, mixed...): iterable<TEntity>)|(Closure(static, OP::*, SyncContextInterface, TEntity, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, iterable<TEntity>, mixed...): iterable<TEntity>)
64: *
65: * @property-read string[]|string|null $Path Path or paths to the endpoint servicing the entity, e.g. "/v1/user"
66: * @property-read mixed[]|null $Query Query parameters applied to the sync operation URL
67: * @property-read HttpHeadersInterface|null $Headers HTTP headers applied to the sync operation request
68: * @property-read CurlerPagerInterface|null $Pager Pagination handler for the endpoint servicing the entity
69: * @property-read bool $AlwaysPaginate Use the pager to process requests even if no pagination is required
70: * @property-read int<-1,max>|null $Expiry Seconds before cached responses expire
71: * @property-read array<OP::*,HttpRequestMethod::*> $MethodMap Array that maps sync operations to HTTP request methods
72: * @property-read (callable(CurlerInterface, HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): CurlerInterface)|null $CurlerCallback Callback applied to the Curler instance created to perform each sync operation
73: * @property-read bool $SyncOneEntityPerRequest Perform CREATE_LIST, UPDATE_LIST and DELETE_LIST operations on one entity per HTTP request
74: * @property-read (callable(HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): HttpSyncDefinition<TEntity,TProvider>)|null $Callback Callback applied to the definition before each sync operation
75: * @property-read mixed[]|null $Args Arguments passed to each sync operation
76: *
77: * @extends AbstractSyncDefinition<TEntity,TProvider>
78: * @implements Buildable<HttpSyncDefinitionBuilder<TEntity,TProvider>>
79: */
80: final class HttpSyncDefinition extends AbstractSyncDefinition implements Buildable
81: {
82: /** @use HasBuilder<HttpSyncDefinitionBuilder<TEntity,TProvider>> */
83: use HasBuilder;
84: use HasMutator;
85:
86: public const DEFAULT_METHOD_MAP = [
87: OP::CREATE => HttpRequestMethod::POST,
88: OP::READ => HttpRequestMethod::GET,
89: OP::UPDATE => HttpRequestMethod::PUT,
90: OP::DELETE => HttpRequestMethod::DELETE,
91: OP::CREATE_LIST => HttpRequestMethod::POST,
92: OP::READ_LIST => HttpRequestMethod::GET,
93: OP::UPDATE_LIST => HttpRequestMethod::PUT,
94: OP::DELETE_LIST => HttpRequestMethod::DELETE,
95: ];
96:
97: /**
98: * Path or paths to the endpoint servicing the entity, e.g. "/v1/user"
99: *
100: * Relative to {@see HttpSyncProvider::getBaseUrl()}.
101: *
102: * Must be set via {@see HttpSyncDefinition::__construct()},
103: * {@see HttpSyncDefinition::withPath()} or
104: * {@see HttpSyncDefinition::$Callback} before a sync operation can be
105: * performed.
106: *
107: * Must not include the provider's base URL.
108: *
109: * Values for named parameters (e.g. `groupId` in `"/group/:groupId/users"`)
110: * are taken from the {@see SyncContextInterface} object received by the
111: * sync operation. The first matching value is used:
112: *
113: * - Values applied explicitly via
114: * {@see ProviderContextInterface::withValue()} or implicitly via
115: * {@see ProviderContextInterface::push()}
116: * - Unclaimed filters passed to the operation via
117: * {@see SyncContextInterface::withFilter()}
118: *
119: * Names are normalised for comparison by converting them to snake_case and
120: * removing any `_id` suffixes.
121: *
122: * If multiple paths are given, each is tried in turn until a path is found
123: * where every parameter can be resolved from the context.
124: *
125: * Filters are only claimed when a path is fully resolved.
126: *
127: * @link https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API
128: *
129: * @var string[]|string|null
130: */
131: protected $Path;
132:
133: /**
134: * Query parameters applied to the sync operation URL
135: *
136: * May be set via {@see HttpSyncDefinition::__construct()},
137: * {@see HttpSyncDefinition::withQuery()} or
138: * {@see HttpSyncDefinition::$Callback}.
139: *
140: * @var mixed[]|null
141: */
142: protected ?array $Query;
143:
144: /**
145: * HTTP headers applied to the sync operation request
146: *
147: * May be set via {@see HttpSyncDefinition::__construct()},
148: * {@see HttpSyncDefinition::withHeaders()} or
149: * {@see HttpSyncDefinition::$Callback}.
150: */
151: protected ?HttpHeadersInterface $Headers;
152:
153: /**
154: * Pagination handler for the endpoint servicing the entity
155: *
156: * May be set via {@see HttpSyncDefinition::__construct()},
157: * {@see HttpSyncDefinition::withPager()} or
158: * {@see HttpSyncDefinition::$Callback}.
159: */
160: protected ?CurlerPagerInterface $Pager;
161:
162: /**
163: * Use the pager to process requests even if no pagination is required
164: */
165: protected bool $AlwaysPaginate;
166:
167: /**
168: * Seconds before cached responses expire
169: *
170: * - `null`: do not cache responses
171: * - `0`: cache responses indefinitely
172: * - `-1` (default): use the value returned by
173: * {@see HttpSyncProvider::getExpiry()}
174: *
175: * May be set via {@see HttpSyncDefinition::__construct()},
176: * {@see HttpSyncDefinition::withExpiry()} or
177: * {@see HttpSyncDefinition::$Callback}.
178: *
179: * @var int<-1,max>|null
180: */
181: protected ?int $Expiry;
182:
183: /**
184: * Array that maps sync operations to HTTP request methods
185: *
186: * May be set via {@see HttpSyncDefinition::__construct()},
187: * {@see HttpSyncDefinition::withMethodMap()} or
188: * {@see HttpSyncDefinition::$Callback}.
189: *
190: * The default method map is {@see HttpSyncDefinition::DEFAULT_METHOD_MAP}.
191: *
192: * @var array<OP::*,HttpRequestMethod::*>
193: */
194: protected array $MethodMap;
195:
196: /**
197: * Callback applied to the Curler instance created to perform each sync
198: * operation
199: *
200: * May be set via {@see HttpSyncDefinition::__construct()},
201: * {@see HttpSyncDefinition::withCurlerCallback()} or
202: * {@see HttpSyncDefinition::$Callback}.
203: *
204: * @var (callable(CurlerInterface, HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): CurlerInterface)|null
205: */
206: protected $CurlerCallback;
207:
208: /**
209: * Perform CREATE_LIST, UPDATE_LIST and DELETE_LIST operations on one entity
210: * per HTTP request
211: */
212: protected bool $SyncOneEntityPerRequest;
213:
214: /**
215: * Callback applied to the definition before each sync operation
216: *
217: * The callback must return the {@see HttpSyncDefinition} it receives even
218: * if no request- or context-specific changes are needed.
219: *
220: * @var (callable(HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): HttpSyncDefinition<TEntity,TProvider>)|null
221: */
222: protected $Callback;
223:
224: /**
225: * Arguments passed to each sync operation
226: *
227: * @var mixed[]|null
228: */
229: protected ?array $Args;
230:
231: /**
232: * @internal
233: *
234: * @param class-string<TEntity> $entity
235: * @param TProvider $provider
236: * @param array<OP::*> $operations
237: * @param string[]|string|null $path
238: * @param mixed[]|null $query
239: * @param (callable(HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): HttpSyncDefinition<TEntity,TProvider>)|null $callback
240: * @param ListConformity::* $conformity
241: * @param FilterPolicy::*|null $filterPolicy
242: * @param int<-1,max>|null $expiry
243: * @param array<OP::*,HttpRequestMethod::*> $methodMap
244: * @param (callable(CurlerInterface, HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): CurlerInterface)|null $curlerCallback
245: * @param array<int-mask-of<OP::*>,Closure(HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): (iterable<TEntity>|TEntity)> $overrides
246: * @phpstan-param array<int-mask-of<OP::*>,OverrideClosure> $overrides
247: * @param array<array-key,array-key|array-key[]>|null $keyMap
248: * @param int-mask-of<ArrayMapperFlag::*> $keyMapFlags
249: * @param PipelineInterface<mixed[],TEntity,SyncPipelineArgument>|null $pipelineFromBackend
250: * @param PipelineInterface<TEntity,mixed[],SyncPipelineArgument>|null $pipelineToBackend
251: * @param SyncEntitySource::*|null $returnEntitiesFrom
252: * @param mixed[]|null $args
253: */
254: public function __construct(
255: string $entity,
256: HttpSyncProvider $provider,
257: array $operations = [],
258: $path = null,
259: ?array $query = null,
260: ?HttpHeadersInterface $headers = null,
261: ?CurlerPagerInterface $pager = null,
262: bool $alwaysPaginate = false,
263: ?callable $callback = null,
264: $conformity = ListConformity::NONE,
265: ?int $filterPolicy = null,
266: ?int $expiry = -1,
267: array $methodMap = HttpSyncDefinition::DEFAULT_METHOD_MAP,
268: ?callable $curlerCallback = null,
269: bool $syncOneEntityPerRequest = false,
270: array $overrides = [],
271: ?array $keyMap = null,
272: int $keyMapFlags = ArrayMapperFlag::ADD_UNMAPPED,
273: ?PipelineInterface $pipelineFromBackend = null,
274: ?PipelineInterface $pipelineToBackend = null,
275: bool $readFromList = false,
276: ?int $returnEntitiesFrom = SyncEntitySource::PROVIDER_OUTPUT,
277: ?array $args = null
278: ) {
279: parent::__construct(
280: $entity,
281: $provider,
282: $operations,
283: $conformity,
284: $filterPolicy,
285: $overrides,
286: $keyMap,
287: $keyMapFlags,
288: $pipelineFromBackend,
289: $pipelineToBackend,
290: $readFromList,
291: $returnEntitiesFrom
292: );
293:
294: $this->Path = $path;
295: $this->Query = $query;
296: $this->Headers = $headers;
297: $this->Pager = $pager;
298: $this->AlwaysPaginate = $pager && $alwaysPaginate;
299: $this->Callback = $callback;
300: $this->Expiry = $expiry;
301: $this->MethodMap = $methodMap;
302: $this->CurlerCallback = $curlerCallback;
303: $this->SyncOneEntityPerRequest = $syncOneEntityPerRequest;
304: $this->Args = $args === null ? null : array_values($args);
305: }
306:
307: /**
308: * Get an instance with the given endpoint path or paths, e.g. "/v1/user"
309: *
310: * @param string[]|string|null $path
311: * @return static
312: */
313: public function withPath($path)
314: {
315: return $this->with('Path', $path);
316: }
317:
318: /**
319: * Get an instance that applies the given query parameters to the sync
320: * operation URL
321: *
322: * @param mixed[]|null $query
323: * @return static
324: */
325: public function withQuery(?array $query)
326: {
327: return $this->with('Query', $query);
328: }
329:
330: /**
331: * Get an instance that applies the given HTTP headers to sync operation
332: * requests
333: *
334: * @return static
335: */
336: public function withHeaders(?HttpHeadersInterface $headers)
337: {
338: return $this->with('Headers', $headers);
339: }
340:
341: /**
342: * Get an instance with the given pagination handler
343: *
344: * @param bool $alwaysPaginate If `true`, the pager is used to process
345: * requests even if no pagination is required.
346: * @return static
347: */
348: public function withPager(?CurlerPagerInterface $pager, bool $alwaysPaginate = false)
349: {
350: return $this
351: ->with('Pager', $pager)
352: ->with('AlwaysPaginate', $pager && $alwaysPaginate);
353: }
354:
355: /**
356: * Get an instance where cached responses expire after the given number of
357: * seconds
358: *
359: * @param int<-1,max>|null $expiry - `null`: do not cache responses
360: * - `0`: cache responses indefinitely
361: * - `-1` (default): use the value returned by
362: * {@see HttpSyncProvider::getExpiry()}
363: * @return static
364: */
365: public function withExpiry(?int $expiry)
366: {
367: return $this->with('Expiry', $expiry);
368: }
369:
370: /**
371: * Get an instance that maps sync operations to the given HTTP request
372: * methods
373: *
374: * @param array<OP::*,HttpRequestMethod::*> $methodMap
375: * @return static
376: */
377: public function withMethodMap(array $methodMap)
378: {
379: return $this->with('MethodMap', $methodMap);
380: }
381:
382: /**
383: * Get an instance that applies the given callback to the Curler instance
384: * created for each sync operation
385: *
386: * @param (callable(CurlerInterface, HttpSyncDefinition<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): CurlerInterface)|null $callback
387: * @return static
388: */
389: public function withCurlerCallback(?callable $callback)
390: {
391: return $this->with('CurlerCallback', $callback);
392: }
393:
394: /**
395: * Get an instance that replaces the arguments passed to each sync operation
396: *
397: * @param mixed[]|null $args
398: * @return static
399: */
400: public function withArgs(?array $args)
401: {
402: return $this->with('Args', $args === null ? null : array_values($args));
403: }
404:
405: /**
406: * @inheritDoc
407: */
408: protected function getClosure($operation): ?Closure
409: {
410: // Return null if no endpoint path has been provided
411: if (
412: ($this->Path === null || $this->Path === [])
413: && $this->Callback === null
414: ) {
415: return null;
416: }
417:
418: switch ($operation) {
419: case OP::CREATE:
420: case OP::UPDATE:
421: case OP::DELETE:
422: return function (
423: SyncContextInterface $ctx,
424: SyncEntityInterface $entity,
425: ...$args
426: ) use ($operation): SyncEntityInterface {
427: $arg = new SyncPipelineArgument($operation, $ctx, $args, null, $entity);
428: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
429: $roundTrip = $this->getRoundTripPipeline($operation);
430: /** @var EntityPipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
431: $toBackend = $this
432: ->getPipelineToBackend()
433: ->send($entity, $arg);
434: /** @var TEntity $entity */
435: return $toBackend
436: ->then(fn($data) => $this->getRoundTripPayload(
437: $this->runHttpOperation($operation, $ctx, $data, ...$args),
438: $entity,
439: $operation,
440: ))
441: ->runInto($roundTrip)
442: ->withConformity($this->Conformity)
443: ->run();
444: };
445:
446: case OP::READ:
447: return function (
448: SyncContextInterface $ctx,
449: $id,
450: ...$args
451: ) use ($operation): SyncEntityInterface {
452: $arg = new SyncPipelineArgument($operation, $ctx, $args, $id);
453: return $this
454: ->getPipelineFromBackend()
455: ->send(
456: $this->runHttpOperation($operation, $ctx, $id, ...$args),
457: $arg,
458: )
459: ->withConformity($this->Conformity)
460: ->run();
461: };
462:
463: case OP::CREATE_LIST:
464: case OP::UPDATE_LIST:
465: case OP::DELETE_LIST:
466: return function (
467: SyncContextInterface $ctx,
468: iterable $entities,
469: ...$args
470: ) use ($operation): iterable {
471: /** @var TEntity */
472: $entity = null;
473: $arg = new SyncPipelineArgument($operation, $ctx, $args, null, $entity);
474: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
475: $roundTrip = $this->getRoundTripPipeline($operation);
476: /** @var StreamPipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
477: $toBackend = $this
478: ->getPipelineToBackend()
479: ->stream($entities, $arg);
480:
481: if ($this->SyncOneEntityPerRequest) {
482: $payload = &$entity;
483: /** @var Closure(TEntity): TEntity */
484: $after = function ($currentPayload) use (&$entity) {
485: return $entity = $currentPayload;
486: };
487: /** @var Closure(mixed[]): mixed[] */
488: $then = function ($data) use ($operation, $ctx, $args, &$payload) {
489: /** @var TEntity $payload */
490: return $this->getRoundTripPayload(
491: $this->runHttpOperation($operation, $ctx, $data, ...$args),
492: $payload,
493: $operation,
494: );
495: };
496: $toBackend = $toBackend
497: ->after($after)
498: ->then($then);
499: } else {
500: $payload = [];
501: /** @var Closure(TEntity): TEntity */
502: $after = function ($currentPayload) use (&$entity, &$payload) {
503: return $payload[] = $entity = $currentPayload;
504: };
505: /** @var Closure(array<mixed[]>): array<mixed[]> */
506: $then = function ($data) use ($operation, $ctx, $args, &$payload) {
507: /** @var TEntity[] $payload */
508: return $this->getRoundTripPayload(
509: $this->runHttpOperation($operation, $ctx, $data, ...$args),
510: $payload,
511: $operation,
512: );
513: };
514: $toBackend = $toBackend
515: ->after($after)
516: ->collectThen($then);
517: }
518:
519: return $toBackend
520: ->startInto($roundTrip)
521: ->withConformity($this->Conformity)
522: ->unlessIf(fn($entity) => $entity === null)
523: ->start();
524: };
525:
526: case OP::READ_LIST:
527: return function (
528: SyncContextInterface $ctx,
529: ...$args
530: ) use ($operation): iterable {
531: /** @var iterable<mixed[]>) */
532: $payload = $this->runHttpOperation($operation, $ctx, ...$args);
533: $arg = new SyncPipelineArgument($operation, $ctx, $args);
534: return $this
535: ->getPipelineFromBackend()
536: ->stream($payload, $arg)
537: ->withConformity($this->Conformity)
538: ->unlessIf(fn($entity) => $entity === null)
539: ->start();
540: };
541: }
542:
543: // @codeCoverageIgnoreStart
544: throw new LogicException(sprintf(
545: 'Invalid SyncOperation: %d',
546: $operation,
547: ));
548: // @codeCoverageIgnoreEnd
549: }
550:
551: /**
552: * Get a closure to perform a sync operation via HTTP
553: *
554: * @param OP::* $operation
555: * @return Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[])
556: */
557: private function getHttpOperationClosure($operation): Closure
558: {
559: // In dry-run mode, return a no-op closure for write operations
560: if (
561: SyncIntrospector::isWriteOperation($operation)
562: && Env::getDryRun()
563: ) {
564: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[]) */
565: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
566: $payload ?? [];
567: }
568:
569: // Pagination with operations other than READ_LIST via GET or POST can't
570: // be safely implemented here, but providers can support pagination with
571: // other operations and/or HTTP methods via overrides
572: switch ([$operation, $this->MethodMap[$operation] ?? null]) {
573: case [OP::READ_LIST, HttpRequestMethod::GET]:
574: /** @var Closure(CurlerInterface, mixed[]|null): iterable<mixed[]> */
575: return fn(CurlerInterface $curler, ?array $query) =>
576: $curler->getPager()
577: ? $curler->getP($query)
578: : $curler->get($query);
579:
580: case [OP::READ_LIST, HttpRequestMethod::POST]:
581: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): iterable<mixed[]> */
582: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
583: $curler->getPager()
584: ? $curler->postP($payload, $query)
585: : $curler->post($payload, $query);
586:
587: case [$operation, HttpRequestMethod::GET]:
588: /** @var Closure(CurlerInterface, mixed[]|null): (iterable<mixed[]>|mixed[]) */
589: return fn(CurlerInterface $curler, ?array $query) =>
590: $curler->get($query);
591:
592: case [$operation, HttpRequestMethod::POST]:
593: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[]) */
594: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
595: $curler->post($payload, $query);
596:
597: case [$operation, HttpRequestMethod::PUT]:
598: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[]) */
599: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
600: $curler->put($payload, $query);
601:
602: case [$operation, HttpRequestMethod::PATCH]:
603: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[]) */
604: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
605: $curler->patch($payload, $query);
606:
607: case [$operation, HttpRequestMethod::DELETE]:
608: /** @var Closure(CurlerInterface, mixed[]|null, mixed[]|null=): (iterable<mixed[]>|mixed[]) */
609: return fn(CurlerInterface $curler, ?array $query, ?array $payload = null) =>
610: $curler->delete($payload, $query);
611: }
612:
613: // @codeCoverageIgnoreStart
614: throw new LogicException(sprintf(
615: 'Invalid SyncOperation or method map: %d',
616: $operation,
617: ));
618: // @codeCoverageIgnoreEnd
619: }
620:
621: /**
622: * Use an HTTP operation closure to perform a sync operation
623: *
624: * @param OP::* $operation
625: * @param mixed ...$args
626: * @return iterable<mixed[]>|mixed[]
627: */
628: private function runHttpOperation($operation, SyncContextInterface $ctx, ...$args)
629: {
630: return (
631: $this->Callback === null
632: ? $this
633: : ($this->Callback)($this, $operation, $ctx, ...$args)
634: )->doRunHttpOperation($operation, $ctx, ...$args);
635: }
636:
637: /**
638: * @param OP::* $operation
639: * @param mixed ...$args
640: * @return iterable<mixed[]>|mixed[]
641: */
642: private function doRunHttpOperation($operation, SyncContextInterface $ctx, ...$args)
643: {
644: if ($this->Path === null || $this->Path === []) {
645: throw new LogicException('Path required');
646: }
647:
648: if ($this->Args !== null) {
649: $args = $this->Args;
650: }
651:
652: $id = $this->getIdFromArgs($operation, $args);
653:
654: $paths = (array) $this->Path;
655: while ($paths) {
656: $claim = [];
657: $idApplied = false;
658: $path = array_shift($paths);
659: // Use this path if it doesn't have any named parameters
660: if (!Regex::matchAll(
661: '/:(?<name>[[:alpha:]_][[:alnum:]_]*+)/',
662: $path,
663: $matches,
664: \PREG_SET_ORDER
665: )) {
666: break;
667: }
668: /** @var string[] */
669: $matches = Arr::unique(Arr::pluck($matches, 'name'));
670: foreach ($matches as $name) {
671: if (
672: $id !== null
673: && Str::snake($name) === 'id'
674: ) {
675: $idApplied = true;
676: $path = $this->applyParameterValue((string) $id, $name, $path);
677: continue;
678: }
679:
680: $value = $ctx->getFilter($name, false);
681: $isFilter = true;
682: if ($value === null) {
683: $value = $ctx->getValue($name);
684: $isFilter = false;
685: }
686:
687: if ($value === null) {
688: if ($paths) {
689: continue 2;
690: }
691: throw new SyncInvalidContextException(
692: sprintf("Unable to resolve '%s' in path '%s'", $name, $path)
693: );
694: }
695:
696: if (is_array($value)) {
697: if ($paths) {
698: continue 2;
699: }
700: throw new SyncInvalidContextException(
701: sprintf("Cannot apply array to '%s' in path '%s'", $name, $path)
702: );
703: }
704:
705: $path = $this->applyParameterValue((string) $value, $name, $path);
706: if ($isFilter) {
707: $claim[] = $name;
708: }
709: }
710: break;
711: }
712:
713: if ($claim) {
714: foreach ($claim as $name) {
715: $ctx->claimFilter($name);
716: }
717: }
718:
719: // If an operation is being performed on a sync entity with a known ID
720: // that hasn't been applied to the path, and no callback has been
721: // provided, add the conventional '/:id' to the endpoint
722: if (
723: $id !== null
724: && !$idApplied
725: && $this->Callback === null
726: && strpos($path, '?') === false
727: ) {
728: $path .= '/' . $this->filterParameterValue(
729: (string) $id, 'id', "$path/:id"
730: );
731: }
732:
733: $curler = $this->Provider->getCurler(
734: $path,
735: $this->Expiry,
736: $this->Headers,
737: $this->Pager,
738: $this->AlwaysPaginate,
739: );
740:
741: if ($this->CurlerCallback) {
742: $curler = ($this->CurlerCallback)($curler, $this, $operation, $ctx, ...$args);
743: }
744:
745: $this->applyFilterPolicy($operation, $ctx, $returnEmpty, $empty);
746: if ($returnEmpty) {
747: return $empty;
748: }
749:
750: $httpClosure = $this->getHttpOperationClosure($operation);
751: $payload = isset($args[0]) && is_array($args[0])
752: ? $args[0]
753: : null;
754:
755: try {
756: return $httpClosure($curler, $this->Query, $payload);
757: } catch (HttpErrorExceptionInterface $ex) {
758: // If a request to READ a known entity fails with 404 (Not Found) or
759: // 410 (Gone), throw a `SyncEntityNotFoundException`
760: if ($operation === OP::READ && $id !== null && (
761: ($status = $ex->getResponse()->getStatusCode()) === 404
762: || $status === 410
763: )) {
764: throw new SyncEntityNotFoundException(
765: $this->Provider,
766: $this->Entity,
767: $id,
768: $ex,
769: );
770: }
771: throw $ex;
772: }
773: }
774:
775: /**
776: * @param OP::* $operation
777: * @param mixed[] $args
778: * @return int|string|null
779: */
780: private function getIdFromArgs($operation, array $args)
781: {
782: if (SyncIntrospector::isListOperation($operation)) {
783: return null;
784: }
785:
786: if ($operation === OP::READ) {
787: $id = $args[0] ?? null;
788:
789: if ($id === null || is_int($id) || is_string($id)) {
790: return $id;
791: }
792:
793: return null;
794: }
795:
796: $entity = $args[0] ?? null;
797:
798: if (!$entity instanceof SyncEntityInterface) {
799: return null;
800: }
801:
802: return $entity->getId();
803: }
804:
805: private function applyParameterValue(string $value, string $name, string $path): string
806: {
807: $value = $this->filterParameterValue($value, $name, $path);
808: return Regex::replace("/:{$name}(?![[:alnum:]_])/", $value, $path);
809: }
810:
811: private function filterParameterValue(string $value, string $name, string $path): string
812: {
813: if (strpos($value, '/') !== false) {
814: throw new UnexpectedValueException(
815: sprintf("Cannot apply value of '%s' to path '%s': %s", $name, $path, $value),
816: );
817: }
818: return rawurlencode($value);
819: }
820:
821: /**
822: * Get a payload for the round trip pipeline
823: *
824: * @template TPayload of TEntity[]|TEntity
825: *
826: * @param iterable<mixed[]>|mixed[] $response
827: * @param TPayload $requestPayload
828: * @param OP::* $operation
829: * @return (TPayload is TEntity[] ? iterable<mixed[]> : mixed[])
830: */
831: private function getRoundTripPayload($response, $requestPayload, $operation)
832: {
833: switch ($this->ReturnEntitiesFrom) {
834: case SyncEntitySource::PROVIDER_OUTPUT:
835: /** @var iterable<mixed[]>|mixed[] */
836: return Env::getDryRun()
837: ? $requestPayload
838: : $response;
839:
840: case SyncEntitySource::OPERATION_INPUT:
841: /** @var iterable<mixed[]>|mixed[] */
842: return $requestPayload;
843:
844: default:
845: // @codeCoverageIgnoreStart
846: throw new SyncInvalidEntitySourceException(
847: $this->Provider, $this->Entity, $operation, $this->ReturnEntitiesFrom
848: );
849: // @codeCoverageIgnoreEnd
850: }
851: }
852:
853: /**
854: * @param OP::* $operation
855: * @return PipelineInterface<mixed[],TEntity,SyncPipelineArgument>
856: */
857: private function getRoundTripPipeline($operation): PipelineInterface
858: {
859: switch ($this->ReturnEntitiesFrom) {
860: case SyncEntitySource::PROVIDER_OUTPUT:
861: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
862: return Env::getDryRun()
863: ? Pipeline::create()
864: : $this->getPipelineFromBackend();
865:
866: case SyncEntitySource::OPERATION_INPUT:
867: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
868: return Pipeline::create();
869:
870: default:
871: // @codeCoverageIgnoreStart
872: throw new SyncInvalidEntitySourceException(
873: $this->Provider, $this->Entity, $operation, $this->ReturnEntitiesFrom
874: );
875: // @codeCoverageIgnoreEnd
876: }
877: }
878:
879: /**
880: * @inheritDoc
881: */
882: public static function getReadableProperties(): array
883: {
884: return [
885: ...parent::getReadableProperties(),
886: 'Path',
887: 'Query',
888: 'Headers',
889: 'Pager',
890: 'AlwaysPaginate',
891: 'Expiry',
892: 'MethodMap',
893: 'CurlerCallback',
894: 'SyncOneEntityPerRequest',
895: 'Callback',
896: 'Args',
897: ];
898: }
899: }
900: