1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Http;
4:
5: use Salient\Contract\Core\DateFormatterInterface;
6: use Salient\Contract\Curler\Exception\RequestExceptionInterface;
7: use Salient\Contract\Curler\CurlerInterface;
8: use Salient\Contract\Curler\CurlerPagerInterface;
9: use Salient\Contract\Http\HttpHeadersInterface;
10: use Salient\Contract\Sync\SyncDefinitionInterface;
11: use Salient\Contract\Sync\SyncEntityInterface;
12: use Salient\Core\Exception\MethodNotImplementedException;
13: use Salient\Core\Facade\Cache;
14: use Salient\Curler\Curler;
15: use Salient\Http\HttpHeaders;
16: use Salient\Sync\Exception\UnreachableBackendException;
17: use Salient\Sync\AbstractSyncProvider;
18: use Salient\Utility\Get;
19:
20: /**
21: * Base class for HTTP-based RESTful API providers
22: */
23: abstract class HttpSyncProvider extends AbstractSyncProvider
24: {
25: /**
26: * Get a Curler instance bound to an API endpoint, optionally overriding the
27: * provider's default configuration
28: *
29: * @param int<-1,max>|null $expiry Number of seconds before cached responses
30: * expire, or:
31: * - `null`: do not cache responses
32: * - `0`: cache responses indefinitely
33: * - `-1` (default): use value returned by {@see getExpiry()}
34: */
35: final public function getCurler(
36: string $path,
37: ?int $expiry = -1,
38: ?HttpHeadersInterface $headers = null,
39: ?CurlerPagerInterface $pager = null,
40: bool $alwaysPaginate = false,
41: ?DateFormatterInterface $dateFormatter = null
42: ): CurlerInterface {
43: $builder = Curler::build()
44: ->uri($this->getEndpointUrl($path))
45: ->headers($headers ?? $this->getHeaders($path))
46: ->pager($pager ?? $this->getPager($path))
47: ->alwaysPaginate($pager ? $alwaysPaginate : $this->getAlwaysPaginate($path))
48: ->dateFormatter($dateFormatter ?? $this->getDateFormatter());
49:
50: if ($expiry === -1) {
51: $expiry = $this->getExpiry($path);
52: }
53:
54: if ($expiry !== null) {
55: $builder = $builder->cacheResponses()->cacheLifetime($expiry);
56: }
57:
58: return $this->filterCurler($builder->build(), $path);
59: }
60:
61: /**
62: * Get the URL of an API endpoint
63: */
64: final public function getEndpointUrl(string $path): string
65: {
66: return $this->getBaseUrl($path) . $path;
67: }
68:
69: /**
70: * Get the base URL of the upstream API
71: *
72: * `$path` should be ignored unless the provider uses endpoint-specific base
73: * URLs to connect to the API. It must not be added to the return value.
74: */
75: abstract protected function getBaseUrl(string $path): string;
76:
77: /**
78: * Override to return HTTP headers required by the upstream API
79: *
80: * @codeCoverageIgnore
81: */
82: protected function getHeaders(string $path): ?HttpHeadersInterface
83: {
84: return null;
85: }
86:
87: /**
88: * Get a new HttpHeaders instance
89: */
90: final protected function headers(): HttpHeaders
91: {
92: return new HttpHeaders();
93: }
94:
95: /**
96: * Override to return a handler for paginated data from the upstream API
97: *
98: * @codeCoverageIgnore
99: */
100: protected function getPager(string $path): ?CurlerPagerInterface
101: {
102: return null;
103: }
104:
105: /**
106: * Override if the pager returned by getPager() should be used to process
107: * requests even if no pagination is required
108: *
109: * @codeCoverageIgnore
110: */
111: protected function getAlwaysPaginate(string $path): bool
112: {
113: return false;
114: }
115:
116: /**
117: * Override to specify the number of seconds before cached responses from
118: * the upstream API expire
119: *
120: * @return int<0,max>|null - `null` (default): do not cache responses
121: * - `0`: cache responses indefinitely
122: *
123: * @codeCoverageIgnore
124: */
125: protected function getExpiry(string $path): ?int
126: {
127: return null;
128: }
129:
130: /**
131: * Override to customise Curler instances before they are used to perform
132: * sync operations
133: *
134: * Values passed to {@see HttpSyncProvider::getCurler()} are applied before
135: * this method is called.
136: *
137: * @codeCoverageIgnore
138: */
139: protected function filterCurler(CurlerInterface $curler, string $path): CurlerInterface
140: {
141: return $curler;
142: }
143:
144: /**
145: * @inheritDoc
146: */
147: final public function getDefinition(string $entity): SyncDefinitionInterface
148: {
149: return $this->getHttpDefinition($entity);
150: }
151:
152: /**
153: * Override to implement sync operations by returning an HttpSyncDefinition
154: * object for the given entity
155: *
156: * @template TEntity of SyncEntityInterface
157: *
158: * @param class-string<TEntity> $entity
159: * @return HttpSyncDefinition<TEntity,$this>
160: *
161: * @codeCoverageIgnore
162: */
163: protected function getHttpDefinition(string $entity): HttpSyncDefinition
164: {
165: return $this->builderFor($entity)->build();
166: }
167:
168: /**
169: * Get a new HttpSyncDefinitionBuilder for an entity
170: *
171: * @template TEntity of SyncEntityInterface
172: *
173: * @param class-string<TEntity> $entity
174: * @return HttpSyncDefinitionBuilder<TEntity,$this>
175: */
176: final protected function builderFor(string $entity): HttpSyncDefinitionBuilder
177: {
178: return HttpSyncDefinition::build()
179: ->entity($entity)
180: ->provider($this);
181: }
182:
183: /**
184: * @inheritDoc
185: */
186: final public function checkHeartbeat(int $ttl = 300)
187: {
188: $key = implode(':', [
189: static::class,
190: __FUNCTION__,
191: Get::hash(implode("\0", $this->getBackendIdentifier())),
192: ]);
193:
194: if (!Cache::has($key)) {
195: try {
196: $resource = $this->getHeartbeat();
197: // @codeCoverageIgnoreStart
198: } catch (RequestExceptionInterface $ex) {
199: throw new UnreachableBackendException(
200: $this,
201: $ex->getMessage(),
202: $ex,
203: );
204: // @codeCoverageIgnoreEnd
205: }
206: Cache::set($key, $resource, $ttl);
207: }
208:
209: return $this;
210: }
211:
212: /**
213: * Get a low-cost resource from the backend to confirm reachability
214: *
215: * @return mixed
216: *
217: * @codeCoverageIgnore
218: */
219: protected function getHeartbeat()
220: {
221: throw new MethodNotImplementedException(
222: static::class,
223: __FUNCTION__,
224: HttpSyncProvider::class,
225: );
226: }
227: }
228: