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