├── docs ├── assets │ ├── request-handlers.png │ └── request-handlers.dot ├── sdk.md ├── files.md └── transports.md ├── src ├── Client │ ├── Factory │ │ ├── FactoryInterface.php │ │ ├── AutoDiscoveredClientFactory.php │ │ ├── SymfonyClientFactory.php │ │ ├── GuzzleClientFactory.php │ │ └── LazyClientLoader.php │ ├── Configurator │ │ └── PluginsConfigurator.php │ └── ClientBuilder.php ├── Uri │ ├── UriBuilderInterface.php │ ├── TemplatedUriBuilder.php │ └── RawUriBuilder.php ├── Encoding │ ├── DecoderInterface.php │ ├── EncoderInterface.php │ ├── Raw │ │ ├── RawDecoder.php │ │ ├── RawEncoder.php │ │ └── EmptyBodyEncoder.php │ ├── Psr7 │ │ └── ResponseDecoder.php │ ├── Stream │ │ ├── StreamDecoder.php │ │ └── StreamEncoder.php │ ├── Json │ │ ├── JsonDecoder.php │ │ └── JsonEncoder.php │ ├── FormUrlencoded │ │ ├── FormUrlencodedDecoder.php │ │ └── FormUrlencodedEncoder.php │ ├── Binary │ │ ├── Extractor │ │ │ ├── FilenameExtractor.php │ │ │ ├── SizeExtractor.php │ │ │ ├── HashExtractor.php │ │ │ ├── ExtensionExtractor.php │ │ │ └── MimeTypeExtractor.php │ │ ├── BinaryFile.php │ │ └── BinaryFileDecoder.php │ └── Mime │ │ └── MultiPartEncoder.php ├── Exception │ └── RuntimeException.php ├── Serializer │ ├── SerializerInterface.php │ ├── SerializerException.php │ └── SymfonySerializer.php ├── Dependency │ ├── MockClientDependency.php │ ├── GuzzleDependency.php │ ├── VcrPluginDependency.php │ ├── SymfonyMimeDependency.php │ └── SymfonyClientDependency.php ├── Transport │ ├── TransportInterface.php │ ├── IO │ │ └── Input │ │ │ ├── RequestConverterInterface.php │ │ │ └── EncodingRequestConverter.php │ ├── Presets │ │ ├── RawPreset.php │ │ ├── JsonPreset.php │ │ ├── PsrPreset.php │ │ ├── FormUrlencodedPreset.php │ │ └── BinaryDownloadPreset.php │ ├── EncodedTransportFactory.php │ ├── CallbackTransport.php │ └── Serializer │ │ └── SerializerTransport.php ├── Formatter │ ├── Factory │ │ └── BasicFormatterFactory.php │ ├── FormatterBuilder.php │ ├── RemoveSensitiveQueryStringsFormatter.php │ ├── RemoveSensitiveHeadersFormatter.php │ └── RemoveSensitiveJsonKeysFormatter.php ├── Request │ ├── RequestInterface.php │ └── Request.php ├── Test │ ├── UseMockClient.php │ ├── UseHttpToolsFactories.php │ ├── UseVcrClient.php │ └── UseHttpFactories.php ├── Sdk │ ├── Rest │ │ ├── CreateTrait.php │ │ ├── FindTrait.php │ │ ├── GetTrait.php │ │ ├── DeleteTrait.php │ │ ├── PatchTrait.php │ │ └── UpdateTrait.php │ └── HttpResource.php └── Plugin │ ├── AcceptLanguagePlugin.php │ └── CallbackPlugin.php ├── grumphp.yml ├── psalm.xml ├── LICENSE ├── static-analysis └── Transport │ └── Serializer │ └── serializer-transport.php ├── examples └── sdk.php ├── composer.json ├── .php-cs-fixer.dist.php └── README.md /docs/assets/request-handlers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phpro/http-tools/HEAD/docs/assets/request-handlers.png -------------------------------------------------------------------------------- /src/Client/Factory/FactoryInterface.php: -------------------------------------------------------------------------------- 1 | $class 15 | * 16 | * @return C 17 | */ 18 | public function deserialize(string $data, string $class); 19 | } 20 | -------------------------------------------------------------------------------- /src/Serializer/SerializerException.php: -------------------------------------------------------------------------------- 1 | $request 17 | * 18 | * @return ResponseType 19 | */ 20 | public function __invoke(RequestInterface $request); 21 | } 22 | -------------------------------------------------------------------------------- /src/Dependency/GuzzleDependency.php: -------------------------------------------------------------------------------- 1 | 7.' 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Dependency/VcrPluginDependency.php: -------------------------------------------------------------------------------- 1 | $request 17 | */ 18 | public function __invoke(RequestInterface $request): PsrRequestInterface; 19 | } 20 | -------------------------------------------------------------------------------- /src/Dependency/SymfonyMimeDependency.php: -------------------------------------------------------------------------------- 1 | 6.0' 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Formatter/Factory/BasicFormatterFactory.php: -------------------------------------------------------------------------------- 1 | 5.4' 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | grumphp: 2 | tasks: 3 | phpcsfixer2: 4 | config: ".php-cs-fixer.dist.php" 5 | config_contains_finder: true 6 | psalm: 7 | config: psalm.xml 8 | show_info: true 9 | no_cache: true 10 | phpunit: ~ 11 | composer: 12 | metadata: 13 | blocking: false 14 | clover_coverage: 15 | clover_file: coverage/clover.xml 16 | level: 100 17 | metadata: 18 | priority: -100 19 | environment: 20 | paths: 21 | - 'tools' 22 | -------------------------------------------------------------------------------- /src/Request/RequestInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class RawDecoder implements DecoderInterface 14 | { 15 | public static function createWithAutodiscoveredPsrFactories(): self 16 | { 17 | return new self(); 18 | } 19 | 20 | public function __invoke(ResponseInterface $response): string 21 | { 22 | return (string) $response->getBody(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Test/UseMockClient.php: -------------------------------------------------------------------------------- 1 | $client; 21 | 22 | return $configurator(new Client()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Encoding/Psr7/ResponseDecoder.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class ResponseDecoder implements DecoderInterface 14 | { 15 | public static function createWithAutodiscoveredPsrFactories(): self 16 | { 17 | return new self(); 18 | } 19 | 20 | public function __invoke(ResponseInterface $response): ResponseInterface 21 | { 22 | return $response; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Encoding/Stream/StreamDecoder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class StreamDecoder implements DecoderInterface 15 | { 16 | public static function createWithAutodiscoveredPsrFactories(): self 17 | { 18 | return new self(); 19 | } 20 | 21 | public function __invoke(ResponseInterface $response): StreamInterface 22 | { 23 | return $response->getBody(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Test/UseHttpToolsFactories.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | private function createToolsRequest( 21 | string $method, 22 | string $uri, 23 | array $uriParams = [], 24 | $body = null, 25 | ): RequestInterface { 26 | return new Request($method, $uri, $uriParams, $body); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/assets/request-handlers.dot: -------------------------------------------------------------------------------- 1 | digraph RequestHandlers { 2 | 3 | subgraph cluster_App { 4 | label="HTTP Value objects"; 5 | 6 | Response [label="Response model"]; 7 | Request [label="Request model"]; 8 | } 9 | 10 | Encoder [label="Encode HTTP data"] 11 | Decoder [label="Decode HTTP data"] 12 | Transport 13 | RequestHandler; 14 | PSRHttpClient [label="PSR-18 HTTP Client"] 15 | 16 | {rank = same; Decoder Encoder } 17 | 18 | Transport -> RequestHandler 19 | RequestHandler -> Transport 20 | Transport -> Encoder 21 | Encoder -> PSRHttpClient 22 | Decoder -> Transport 23 | PSRHttpClient -> Decoder 24 | RequestHandler -> Response 25 | Request -> RequestHandler 26 | } -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Encoding/Stream/StreamEncoder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class StreamEncoder implements EncoderInterface 15 | { 16 | public static function createWithAutodiscoveredPsrFactories(): self 17 | { 18 | return new self(); 19 | } 20 | 21 | /** 22 | * @param StreamInterface $data 23 | */ 24 | public function __invoke(RequestInterface $request, $data): RequestInterface 25 | { 26 | return $request->withBody($data); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Encoding/Json/JsonDecoder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class JsonDecoder implements DecoderInterface 15 | { 16 | public static function createWithAutodiscoveredPsrFactories(): self 17 | { 18 | return new self(); 19 | } 20 | 21 | public function __invoke(ResponseInterface $response): array 22 | { 23 | if (!$responseBody = (string) $response->getBody()) { 24 | return []; 25 | } 26 | 27 | return (array) Json\decode($responseBody, true); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Sdk/Rest/CreateTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('POST', $this->path(), [], $data); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Sdk/Rest/FindTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('GET', $this->path().'/'.$identifier, [], null); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Sdk/Rest/GetTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('GET', $this->path(), $uriParameters, null); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Uri/TemplatedUriBuilder.php: -------------------------------------------------------------------------------- 1 | defaultVariables = $defaultVariables; 19 | } 20 | 21 | public function __invoke(RequestInterface $request): UriInterface 22 | { 23 | $uriTemplate = new UriTemplate($request->uri(), $this->defaultVariables); 24 | 25 | return Http::new($uriTemplate->expand($request->uriParameters())); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Encoding/FormUrlencoded/FormUrlencodedDecoder.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class FormUrlencodedDecoder implements DecoderInterface 14 | { 15 | public static function createWithAutodiscoveredPsrFactories(): self 16 | { 17 | return new self(); 18 | } 19 | 20 | public function __invoke(ResponseInterface $response): array 21 | { 22 | if (!$responseBody = (string) $response->getBody()) { 23 | return []; 24 | } 25 | 26 | parse_str($responseBody, $output); 27 | 28 | return $output; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Plugin/AcceptLanguagePlugin.php: -------------------------------------------------------------------------------- 1 | acceptLanguage = $acceptLanguage; 18 | } 19 | 20 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 21 | { 22 | return $next( 23 | $request->withAddedHeader( 24 | 'Accept-Language', 25 | $this->acceptLanguage 26 | ) 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Sdk/Rest/DeleteTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('DELETE', $this->path().'/'.$identifier, [], null); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Client/Factory/AutoDiscoveredClientFactory.php: -------------------------------------------------------------------------------- 1 | $options 18 | * @param list $middlewares 19 | */ 20 | public static function create(iterable $middlewares, array $options = []): ClientInterface 21 | { 22 | return PluginsConfigurator::configure(Psr18ClientDiscovery::find(), $middlewares); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Encoding/Binary/Extractor/FilenameExtractor.php: -------------------------------------------------------------------------------- 1 | getHeader('Content-Disposition')); 19 | if (null === $disposition) { 20 | return null; 21 | } 22 | 23 | return try_catch( 24 | static fn () => ContentDisposition::parse($disposition)->getFilename(), 25 | static fn () => null 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Sdk/Rest/PatchTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('PATCH', $this->path().'/'.$identifier, [], $data); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Sdk/Rest/UpdateTrait.php: -------------------------------------------------------------------------------- 1 | $request */ 26 | $request = new Request('PUT', $this->path().'/'.$identifier, [], $data); 27 | 28 | return $this->transport()($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Encoding/Binary/Extractor/SizeExtractor.php: -------------------------------------------------------------------------------- 1 | getBody()->getSize(); 18 | if (null !== $size) { 19 | return $size; 20 | } 21 | 22 | $length = first($response->getHeader('Content-Length')); 23 | if (null !== $length) { 24 | return try_catch( 25 | static fn () => int()->coerce($length), 26 | static fn () => null 27 | ); 28 | } 29 | 30 | return null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Sdk/HttpResource.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private TransportInterface $transport; 18 | 19 | /** 20 | * @param TransportInterface $transport 21 | */ 22 | public function __construct(TransportInterface $transport) 23 | { 24 | $this->transport = $transport; 25 | } 26 | 27 | abstract protected function path(): string; 28 | 29 | /** 30 | * @return TransportInterface 31 | */ 32 | protected function transport(): TransportInterface 33 | { 34 | return $this->transport; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Client/Configurator/PluginsConfigurator.php: -------------------------------------------------------------------------------- 1 | $plugins 20 | * 21 | * @return PluginClient 22 | */ 23 | public static function configure(ClientInterface $client, iterable $plugins): ClientInterface 24 | { 25 | Assert::allIsInstanceOf($plugins, Plugin::class); 26 | 27 | return new PluginClient( 28 | $client, 29 | is_array($plugins) ? $plugins : iterator_to_array($plugins, false), 30 | [] 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Encoding/Binary/Extractor/HashExtractor.php: -------------------------------------------------------------------------------- 1 | getBody(); 21 | 22 | $pos = $body->tell(); 23 | if ($pos > 0) { 24 | $body->rewind(); 25 | } 26 | 27 | $context = Context::forAlgorithm($this->algorithm); 28 | while (!$body->eof()) { 29 | $context = $context->update($body->read(1048576)); 30 | } 31 | 32 | $body->seek($pos); 33 | 34 | return $context->finalize(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Transport/Presets/RawPreset.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public static function create( 20 | ClientInterface $client, 21 | UriBuilderInterface $uriBuilder, 22 | ): TransportInterface { 23 | return EncodedTransportFactory::create( 24 | $client, 25 | $uriBuilder, 26 | RawEncoder::createWithAutodiscoveredPsrFactories(), 27 | RawDecoder::createWithAutodiscoveredPsrFactories() 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Transport/Presets/JsonPreset.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public static function create( 20 | ClientInterface $client, 21 | UriBuilderInterface $uriBuilder, 22 | ): TransportInterface { 23 | return EncodedTransportFactory::create( 24 | $client, 25 | $uriBuilder, 26 | JsonEncoder::createWithAutodiscoveredPsrFactories(), 27 | JsonDecoder::createWithAutodiscoveredPsrFactories() 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Plugin/CallbackPlugin.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 28 | } 29 | 30 | /** 31 | * @param Step $next 32 | * @param Step $first 33 | */ 34 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 35 | { 36 | return ($this->callback)($request, $next, $first); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Client/Factory/SymfonyClientFactory.php: -------------------------------------------------------------------------------- 1 | $middlewares 20 | */ 21 | public static function create(iterable $middlewares, array $options = []): ClientInterface 22 | { 23 | SymfonyClientDependency::guard(); 24 | 25 | return PluginsConfigurator::configure( 26 | new HttplugClient( 27 | new CurlHttpClient($options) 28 | ), 29 | $middlewares 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Transport/Presets/PsrPreset.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public static function create( 21 | ClientInterface $client, 22 | UriBuilderInterface $uriBuilder, 23 | ): TransportInterface { 24 | return EncodedTransportFactory::create( 25 | $client, 26 | $uriBuilder, 27 | RawEncoder::createWithAutodiscoveredPsrFactories(), 28 | ResponseDecoder::createWithAutodiscoveredPsrFactories() 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Client/Factory/GuzzleClientFactory.php: -------------------------------------------------------------------------------- 1 | $middlewares 19 | */ 20 | public static function create(iterable $middlewares, array $options = []): ClientInterface 21 | { 22 | Assert::allIsCallable($middlewares); 23 | GuzzleDependency::guard(); 24 | 25 | $stack = HandlerStack::create(); 26 | 27 | foreach ($middlewares as $middleware) { 28 | $stack->push($middleware); 29 | } 30 | 31 | return new Client( 32 | array_merge($options, ['handler' => $stack]) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Encoding/Binary/Extractor/ExtensionExtractor.php: -------------------------------------------------------------------------------- 1 | getExtensions($mimeType); 22 | if ($extensions) { 23 | return first($extensions); 24 | } 25 | } 26 | 27 | $originalName = (new FilenameExtractor())($response); 28 | if (null !== $originalName) { 29 | return pathinfo($originalName, PATHINFO_EXTENSION) ?: null; 30 | } 31 | 32 | return null; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Uri/RawUriBuilder.php: -------------------------------------------------------------------------------- 1 | uriFactory = $uriFactory; 19 | } 20 | 21 | public static function createWithAutodiscoveredPsrFactories(): self 22 | { 23 | return new self(Psr17FactoryDiscovery::findUriFactory()); 24 | } 25 | 26 | public function __invoke(RequestInterface $request): UriInterface 27 | { 28 | $uri = $this->uriFactory->createUri($request->uri()); 29 | 30 | if (!$request->uriParameters()) { 31 | return $uri; 32 | } 33 | 34 | return $uri->withQuery(http_build_query($request->uriParameters())); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Encoding/Raw/RawEncoder.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class RawEncoder implements EncoderInterface 16 | { 17 | private StreamFactoryInterface $streamFactory; 18 | 19 | public function __construct(StreamFactoryInterface $streamFactory) 20 | { 21 | $this->streamFactory = $streamFactory; 22 | } 23 | 24 | public static function createWithAutodiscoveredPsrFactories(): self 25 | { 26 | return new self( 27 | Psr17FactoryDiscovery::findStreamFactory() 28 | ); 29 | } 30 | 31 | /** 32 | * @param string $data 33 | */ 34 | public function __invoke(RequestInterface $request, $data): RequestInterface 35 | { 36 | return $request->withBody($this->streamFactory->createStream($data)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Encoding/Raw/EmptyBodyEncoder.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class EmptyBodyEncoder implements EncoderInterface 16 | { 17 | private StreamFactoryInterface $streamFactory; 18 | 19 | public function __construct(StreamFactoryInterface $streamFactory) 20 | { 21 | $this->streamFactory = $streamFactory; 22 | } 23 | 24 | public static function createWithAutodiscoveredPsrFactories(): self 25 | { 26 | return new self( 27 | Psr17FactoryDiscovery::findStreamFactory() 28 | ); 29 | } 30 | 31 | /** 32 | * @param null $data 33 | */ 34 | public function __invoke(RequestInterface $request, $data): RequestInterface 35 | { 36 | return $request->withBody($this->streamFactory->createStream()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Encoding/Binary/Extractor/MimeTypeExtractor.php: -------------------------------------------------------------------------------- 1 | getHeader('Content-Type')); 19 | if (null !== $contentType && '' !== $contentType) { 20 | return $contentType; 21 | } 22 | 23 | $originalName = (new FilenameExtractor())($response); 24 | if (null !== $originalName) { 25 | if ($extension = pathinfo($originalName, PATHINFO_EXTENSION)) { 26 | SymfonyMimeDependency::guard(); 27 | $mimeTypes = MimeTypes::getDefault()->getMimeTypes($extension); 28 | 29 | return first($mimeTypes); 30 | } 31 | } 32 | 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Test/UseVcrClient.php: -------------------------------------------------------------------------------- 1 | infinite Phpro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Encoding/Binary/BinaryFile.php: -------------------------------------------------------------------------------- 1 | stream; 24 | } 25 | 26 | public function fileSizeInBytes(): ?int 27 | { 28 | return $this->fileSizeInBytes; 29 | } 30 | 31 | public function mimeType(): ?string 32 | { 33 | return $this->mimeType; 34 | } 35 | 36 | public function fileName(): ?string 37 | { 38 | return $this->fileName; 39 | } 40 | 41 | public function extension(): ?string 42 | { 43 | return $this->extension; 44 | } 45 | 46 | public function hash(): ?string 47 | { 48 | return $this->hash; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Transport/Presets/FormUrlencodedPreset.php: -------------------------------------------------------------------------------- 1 | |null $decoder 19 | * 20 | * @return TransportInterface 21 | */ 22 | public static function create( 23 | ClientInterface $client, 24 | UriBuilderInterface $uriBuilder, 25 | ?DecoderInterface $decoder = null, 26 | ): TransportInterface { 27 | return EncodedTransportFactory::create( 28 | $client, 29 | $uriBuilder, 30 | FormUrlencodedEncoder::createWithAutodiscoveredPsrFactories(), 31 | $decoder ?? FormUrlencodedDecoder::createWithAutodiscoveredPsrFactories() 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Serializer/SymfonySerializer.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 17 | $this->format = $format; 18 | } 19 | 20 | public function serialize(mixed $data): string 21 | { 22 | return $this->serializer->serialize($data, $this->format); 23 | } 24 | 25 | /** 26 | * @psalm-suppress UnnecessaryVarAnnotation - It knows $result ... Sometimes it does, sometimes it dont... :') 27 | * 28 | * @template C 29 | * 30 | * @param class-string $class 31 | * 32 | * @return C 33 | */ 34 | public function deserialize(string $data, string $class) 35 | { 36 | /** @var C $result */ 37 | $result = $this->serializer->deserialize($data, $class, $this->format); 38 | 39 | return $result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static-analysis/Transport/Serializer/serializer-transport.php: -------------------------------------------------------------------------------- 1 | $x 18 | * 19 | * @return null 20 | */ 21 | function testEmptySerializer(SerializerTransport $x) 22 | { 23 | return $x(new Request('GET', '/', [], new Foo())); 24 | } 25 | 26 | /** 27 | * @param SerializerTransport $x 28 | */ 29 | function testTargetSerializer(SerializerTransport $x): Foo 30 | { 31 | return $x(new Request('GET', '/', [], new Foo())); 32 | } 33 | 34 | /** 35 | * @param TransportInterface $transport 36 | */ 37 | function test(SerializerInterface $serializer, TransportInterface $transport): void 38 | { 39 | /** @var SerializerTransport $serializerTransport */ 40 | $serializerTransport = new SerializerTransport($serializer, $transport); 41 | 42 | testEmptySerializer($serializerTransport); 43 | 44 | testTargetSerializer($serializerTransport->withOutputType(Foo::class)); 45 | } 46 | -------------------------------------------------------------------------------- /src/Test/UseHttpFactories.php: -------------------------------------------------------------------------------- 1 | createRequest($method, $uri); 20 | } 21 | 22 | private static function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface 23 | { 24 | return Psr17FactoryDiscovery::findResponseFactory()->createResponse($code, $reasonPhrase); 25 | } 26 | 27 | private static function createStream(string $content): StreamInterface 28 | { 29 | return Psr17FactoryDiscovery::findStreamFactory()->createStream($content); 30 | } 31 | 32 | private static function createEmptyHttpClientException(string $message): ClientExceptionInterface&Exception 33 | { 34 | return new class($message) extends RuntimeException implements ClientExceptionInterface, Exception { 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Encoding/Json/JsonEncoder.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class JsonEncoder implements EncoderInterface 17 | { 18 | private StreamFactoryInterface $streamFactory; 19 | 20 | public function __construct(StreamFactoryInterface $streamFactory) 21 | { 22 | $this->streamFactory = $streamFactory; 23 | } 24 | 25 | public static function createWithAutodiscoveredPsrFactories(): self 26 | { 27 | return new self( 28 | Psr17FactoryDiscovery::findStreamFactory() 29 | ); 30 | } 31 | 32 | /** 33 | * @param array|null $data 34 | */ 35 | public function __invoke(RequestInterface $request, $data): RequestInterface 36 | { 37 | return $request 38 | ->withAddedHeader('Content-Type', 'application/json') 39 | ->withAddedHeader('Accept', 'application/json') 40 | ->withBody($this->streamFactory->createStream( 41 | null !== $data ? Json\encode($data) : '' 42 | )); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Encoding/FormUrlencoded/FormUrlencodedEncoder.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class FormUrlencodedEncoder implements EncoderInterface 19 | { 20 | private StreamFactoryInterface $streamFactory; 21 | 22 | public function __construct(StreamFactoryInterface $streamFactory) 23 | { 24 | $this->streamFactory = $streamFactory; 25 | } 26 | 27 | public static function createWithAutodiscoveredPsrFactories(): self 28 | { 29 | return new self( 30 | Psr17FactoryDiscovery::findStreamFactory() 31 | ); 32 | } 33 | 34 | /** 35 | * @param array|null $data 36 | */ 37 | public function __invoke(RequestInterface $request, $data): RequestInterface 38 | { 39 | return $request 40 | ->withAddedHeader('Content-Type', 'application/x-www-form-urlencoded') 41 | ->withBody($this->streamFactory->createStream( 42 | null !== $data ? http_build_query($data, encoding_type: PHP_QUERY_RFC1738) : '' 43 | )); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Client/Factory/LazyClientLoader.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | private string $factory; 21 | private iterable $middlewares; 22 | private array $options; 23 | private ?ClientInterface $loaded = null; 24 | 25 | /** 26 | * @param class-string $factory 27 | */ 28 | public function __construct(string $factory, iterable $middlewares, array $options = []) 29 | { 30 | $this->factory = $factory; 31 | $this->middlewares = $middlewares; 32 | $this->options = $options; 33 | } 34 | 35 | public function load(): ClientInterface 36 | { 37 | if (!$this->loaded) { 38 | /** @psalm-suppress DocblockTypeContradiction */ 39 | Assert::subclassOf($this->factory, FactoryInterface::class); 40 | 41 | $this->loaded = ($this->factory)::create($this->middlewares, $this->options); 42 | } 43 | 44 | return $this->loaded; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Formatter/FormatterBuilder.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private array $decorators = []; 24 | 25 | public static function default( 26 | ): FormatterBuilder { 27 | return new self(); 28 | } 29 | 30 | public function withDebug(bool $debug = true): self 31 | { 32 | $this->debug = $debug; 33 | 34 | return $this; 35 | } 36 | 37 | public function withMaxBodyLength(int $maxBodyLength): self 38 | { 39 | $this->maxBodyLength = $maxBodyLength; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * @param Decorator $decorator 46 | */ 47 | public function addDecorator(Closure $decorator): self 48 | { 49 | $this->decorators[] = $decorator; 50 | 51 | return $this; 52 | } 53 | 54 | public function build(): Formatter 55 | { 56 | return Fun\pipe(...$this->decorators)( 57 | BasicFormatterFactory::create($this->debug, $this->maxBodyLength) 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Request/Request.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class Request implements RequestInterface 17 | { 18 | /** 19 | * @var Method 20 | */ 21 | private string $method; 22 | private string $uri; 23 | private array $uriParameters; 24 | 25 | /** 26 | * @var RequestType 27 | */ 28 | private $body; 29 | 30 | /** 31 | * @param Method $method 32 | * @param RequestType $body 33 | */ 34 | public function __construct(string $method, string $uri, array $uriParameters, $body) 35 | { 36 | $this->method = $method; 37 | $this->uri = $uri; 38 | $this->uriParameters = $uriParameters; 39 | $this->body = $body; 40 | } 41 | 42 | /** 43 | * @return Method 44 | */ 45 | public function method(): string 46 | { 47 | return $this->method; 48 | } 49 | 50 | public function uri(): string 51 | { 52 | return $this->uri; 53 | } 54 | 55 | public function uriParameters(): array 56 | { 57 | return $this->uriParameters; 58 | } 59 | 60 | /** 61 | * @return RequestType 62 | */ 63 | public function body() 64 | { 65 | return $this->body; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Transport/EncodedTransportFactory.php: -------------------------------------------------------------------------------- 1 | $encoder 22 | * @param DecoderInterface $decoder 23 | * 24 | * @return TransportInterface 25 | */ 26 | public static function create( 27 | ClientInterface $client, 28 | UriBuilderInterface $uriBuilder, 29 | EncoderInterface $encoder, 30 | DecoderInterface $decoder, 31 | ): TransportInterface { 32 | /** @var CallbackTransport $transport */ 33 | $transport = new CallbackTransport( 34 | EncodingRequestConverter::createWithAutodiscoveredPsrFactories($uriBuilder, $encoder), 35 | static fn (PsrRequestInterface $request) => $client->sendRequest($request), 36 | static fn (ResponseInterface $response) => $decoder($response) 37 | ); 38 | 39 | return $transport; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Encoding/Mime/MultiPartEncoder.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class MultiPartEncoder implements EncoderInterface 21 | { 22 | private StreamFactoryInterface $streamFactory; 23 | 24 | public function __construct(StreamFactoryInterface $streamFactory) 25 | { 26 | SymfonyMimeDependency::guard(); 27 | 28 | $this->streamFactory = $streamFactory; 29 | } 30 | 31 | public static function createWithAutodiscoveredPsrFactories(): self 32 | { 33 | return new self( 34 | Psr17FactoryDiscovery::findStreamFactory() 35 | ); 36 | } 37 | 38 | /** 39 | * @param MultiPart $data 40 | */ 41 | public function __invoke(RequestInterface $request, $data): RequestInterface 42 | { 43 | return $request 44 | ->withAddedHeader( 45 | 'Content-Type', 46 | string()->assert($data->getPreparedHeaders()->get('content-type')?->getBodyAsString()) 47 | ) 48 | ->withBody($this->streamFactory->createStream( 49 | $data->bodyToString() 50 | )); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Transport/CallbackTransport.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class CallbackTransport implements TransportInterface 18 | { 19 | /** 20 | * @var callable(RequestInterface): PsrRequestInterface 21 | */ 22 | private $requestConverter; 23 | 24 | /** 25 | * @var callable(PsrResponseInterface): ResponseType 26 | */ 27 | private $responseConverter; 28 | 29 | /** 30 | * @var callable(PsrRequestInterface): PsrResponseInterface 31 | */ 32 | private $sender; 33 | 34 | /** 35 | * @param callable(RequestInterface): PsrRequestInterface $requestConverter 36 | * @param callable(PsrRequestInterface): PsrResponseInterface $sender 37 | * @param callable(PsrResponseInterface): ResponseType $responseConverter 38 | */ 39 | public function __construct( 40 | callable $requestConverter, 41 | callable $sender, 42 | callable $responseConverter, 43 | ) { 44 | $this->requestConverter = $requestConverter; 45 | $this->sender = $sender; 46 | $this->responseConverter = $responseConverter; 47 | } 48 | 49 | /** 50 | * @param RequestInterface $request 51 | * 52 | * @return ResponseType 53 | */ 54 | public function __invoke(RequestInterface $request) 55 | { 56 | $httpRequest = ($this->requestConverter)($request); 57 | $httpResponse = ($this->sender)($httpRequest); 58 | 59 | return ($this->responseConverter)($httpResponse); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Transport/IO/Input/EncodingRequestConverter.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class EncodingRequestConverter implements RequestConverterInterface 20 | { 21 | private RequestFactoryInterface $requestFactory; 22 | private UriBuilderInterface $uriBuilder; 23 | /** 24 | * @var EncoderInterface 25 | */ 26 | private EncoderInterface $encoder; 27 | 28 | /** 29 | * @param EncoderInterface $encoder 30 | */ 31 | public function __construct( 32 | RequestFactoryInterface $requestFactory, 33 | UriBuilderInterface $uriBuilder, 34 | EncoderInterface $encoder, 35 | ) { 36 | $this->requestFactory = $requestFactory; 37 | $this->encoder = $encoder; 38 | $this->uriBuilder = $uriBuilder; 39 | } 40 | 41 | public static function createWithAutodiscoveredPsrFactories( 42 | UriBuilderInterface $uriBuilder, 43 | EncoderInterface $encoder, 44 | ): self { 45 | return new self( 46 | Psr17FactoryDiscovery::findRequestFactory(), 47 | $uriBuilder, 48 | $encoder 49 | ); 50 | } 51 | 52 | public function __invoke(RequestInterface $request): PsrRequestInterface 53 | { 54 | return ($this->encoder)( 55 | $this->requestFactory->createRequest( 56 | $request->method(), 57 | ($this->uriBuilder)($request) 58 | ), 59 | $request->body() 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Transport/Presets/BinaryDownloadPreset.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public static function create( 25 | ClientInterface $client, 26 | UriBuilderInterface $uriBuilder, 27 | ): TransportInterface { 28 | return self::withEmptyRequest($client, $uriBuilder); 29 | } 30 | 31 | /** 32 | * @return TransportInterface 33 | */ 34 | public static function withEmptyRequest( 35 | ClientInterface $client, 36 | UriBuilderInterface $uriBuilder, 37 | ): TransportInterface { 38 | return EncodedTransportFactory::create( 39 | $client, 40 | $uriBuilder, 41 | EmptyBodyEncoder::createWithAutodiscoveredPsrFactories(), 42 | BinaryFileDecoder::createWithAutodiscoveredPsrFactories() 43 | ); 44 | } 45 | 46 | /** 47 | * @return TransportInterface 48 | */ 49 | public static function withMultiPartRequest( 50 | ClientInterface $client, 51 | UriBuilderInterface $uriBuilder, 52 | ): TransportInterface { 53 | return EncodedTransportFactory::create( 54 | $client, 55 | $uriBuilder, 56 | MultiPartEncoder::createWithAutodiscoveredPsrFactories(), 57 | BinaryFileDecoder::createWithAutodiscoveredPsrFactories() 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Transport/Serializer/SerializerTransport.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class SerializerTransport implements TransportInterface 19 | { 20 | /** 21 | * @var class-string|null 22 | */ 23 | private ?string $outputType = null; 24 | private SerializerInterface $serializer; 25 | 26 | /** 27 | * @var TransportInterface 28 | */ 29 | private TransportInterface $transport; 30 | 31 | /** 32 | * @param TransportInterface $transport 33 | */ 34 | public function __construct( 35 | SerializerInterface $serializer, 36 | TransportInterface $transport, 37 | ) { 38 | $this->transport = $transport; 39 | $this->serializer = $serializer; 40 | } 41 | 42 | /** 43 | * @template NewResponseType of object 44 | * 45 | * @param class-string $output 46 | * 47 | * @return SerializerTransport 48 | */ 49 | public function withOutputType(string $output): self 50 | { 51 | /** @var SerializerTransport $new */ 52 | $new = new SerializerTransport($this->serializer, $this->transport); 53 | $new->outputType = $output; 54 | 55 | return $new; 56 | } 57 | 58 | public function __invoke(RequestInterface $request) 59 | { 60 | $response = ($this->transport)( 61 | new Request( 62 | $request->method(), 63 | $request->uri(), 64 | $request->uriParameters(), 65 | null === $request->body() ? '' : $this->serializer->serialize($request->body()) 66 | ) 67 | ); 68 | 69 | if (null === $this->outputType) { 70 | return; 71 | } 72 | 73 | return $this->serializer->deserialize($response, $this->outputType); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/sdk.php: -------------------------------------------------------------------------------- 1 | 44 | */ 45 | final class UsersResource extends HttpResource 46 | { 47 | use CreateTrait; 48 | use DeleteTrait; 49 | use FindTrait; 50 | use GetTrait; 51 | use PatchTrait; 52 | use UpdateTrait; 53 | 54 | protected function path(): string 55 | { 56 | return '/users'; 57 | } 58 | } 59 | 60 | /** 61 | * @template ResultType 62 | */ 63 | final class Sdk 64 | { 65 | /** 66 | * @var UsersResource 67 | * 68 | * @psalm-readonly 69 | */ 70 | public UsersResource $users; 71 | 72 | /** 73 | * @param TransportInterface $transport 74 | */ 75 | public function __construct(TransportInterface $transport) 76 | { 77 | $this->users = new UsersResource($transport); 78 | } 79 | } 80 | 81 | /** @var Sdk $sdk */ 82 | $sdk = new Sdk($transport); 83 | 84 | /** @psalm-suppress ForbiddenCode */ 85 | var_dump($sdk->users->find('veewee')); 86 | -------------------------------------------------------------------------------- /src/Encoding/Binary/BinaryFileDecoder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class BinaryFileDecoder implements DecoderInterface 15 | { 16 | /** 17 | * @var callable(ResponseInterface): ?int 18 | */ 19 | private $sizeExtractor; 20 | 21 | /** 22 | * @var callable(ResponseInterface): ?string 23 | */ 24 | private $mimeTypeExtractor; 25 | 26 | /** 27 | * @var callable(ResponseInterface): ?string 28 | */ 29 | private $fileNameExtractor; 30 | 31 | /** 32 | * @var callable(ResponseInterface): ?string 33 | */ 34 | private $extensionExtractor; 35 | 36 | /** 37 | * @var callable(ResponseInterface): ?string 38 | */ 39 | private $hashExtractor; 40 | 41 | /** 42 | * @param callable(ResponseInterface): ?int $sizeExtractor 43 | * @param callable(ResponseInterface): ?string $mimeTypeExtractor 44 | * @param callable(ResponseInterface): ?string $fileNameExtractor 45 | * @param callable(ResponseInterface): ?string $extensionExtractor 46 | * @param callable(ResponseInterface): ?string $hashExtractor 47 | */ 48 | public function __construct( 49 | callable $sizeExtractor, 50 | callable $mimeTypeExtractor, 51 | callable $fileNameExtractor, 52 | callable $extensionExtractor, 53 | callable $hashExtractor, 54 | ) { 55 | $this->sizeExtractor = $sizeExtractor; 56 | $this->mimeTypeExtractor = $mimeTypeExtractor; 57 | $this->fileNameExtractor = $fileNameExtractor; 58 | $this->extensionExtractor = $extensionExtractor; 59 | $this->hashExtractor = $hashExtractor; 60 | } 61 | 62 | public static function createWithAutodiscoveredPsrFactories(): self 63 | { 64 | return new self( 65 | new Extractor\SizeExtractor(), 66 | new Extractor\MimeTypeExtractor(), 67 | new Extractor\FilenameExtractor(), 68 | new Extractor\ExtensionExtractor(), 69 | new Extractor\HashExtractor(Algorithm::Md5), 70 | ); 71 | } 72 | 73 | public function __invoke(ResponseInterface $response): BinaryFile 74 | { 75 | return new BinaryFile( 76 | $response->getBody(), 77 | ($this->sizeExtractor)($response), 78 | ($this->mimeTypeExtractor)($response), 79 | ($this->fileNameExtractor)($response), 80 | ($this->extensionExtractor)($response), 81 | ($this->hashExtractor)($response), 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Formatter/RemoveSensitiveQueryStringsFormatter.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $sensitiveKeys; 23 | 24 | /** 25 | * @param non-empty-list $sensitiveKeys 26 | */ 27 | public function __construct( 28 | HttpFormatter $formatter, 29 | array $sensitiveKeys, 30 | ) { 31 | $this->formatter = $formatter; 32 | $this->sensitiveKeys = $sensitiveKeys; 33 | } 34 | 35 | /** 36 | * @param non-empty-list $sensitiveKeys 37 | * 38 | * @return Decorator 39 | */ 40 | public static function createDecorator(array $sensitiveKeys): Closure 41 | { 42 | return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveKeys); 43 | } 44 | 45 | public function formatRequest(RequestInterface $request): string 46 | { 47 | return $this->removeQueryStrings($request); 48 | } 49 | 50 | /** @psalm-suppress DeprecatedMethod */ 51 | public function formatResponse(ResponseInterface $response): string 52 | { 53 | return $this->formatter->formatResponse($response); 54 | } 55 | 56 | public function formatResponseForRequest(ResponseInterface $response, RequestInterface $request): string 57 | { 58 | if (!method_exists($this->formatter, 'formatResponseForRequest')) { 59 | return $this->formatResponse($response); 60 | } 61 | 62 | /** 63 | * @psalm-suppress UnnecessaryVarAnnotation 64 | * 65 | * @psalm-var string $formatted 66 | */ 67 | $formatted = $this->formatter->formatResponseForRequest($response, $request); 68 | 69 | return $formatted; 70 | } 71 | 72 | private function removeQueryStrings(RequestInterface $request): string 73 | { 74 | $uri = $request->getUri(); 75 | $query = $uri->getQuery(); 76 | 77 | $result = []; 78 | parse_str($query, $result); 79 | 80 | foreach ($this->sensitiveKeys as $key) { 81 | if (!array_key_exists($key, $result)) { 82 | continue; 83 | } 84 | 85 | $result[$key] = 'xxxx'; 86 | } 87 | 88 | return $this->formatter->formatRequest( 89 | $request->withUri( 90 | $uri->withQuery(http_build_query($result)) 91 | ) 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Formatter/RemoveSensitiveHeadersFormatter.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $sensitiveHeaders; 27 | 28 | /** 29 | * @param non-empty-list $sensitiveHeaders 30 | */ 31 | public function __construct(HttpFormatter $formatter, array $sensitiveHeaders) 32 | { 33 | $this->formatter = $formatter; 34 | $this->sensitiveHeaders = $sensitiveHeaders; 35 | } 36 | 37 | /** 38 | * @param non-empty-list $sensitiveHeaders 39 | * 40 | * @return Decorator 41 | */ 42 | public static function createDecorator(array $sensitiveHeaders): Closure 43 | { 44 | return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveHeaders); 45 | } 46 | 47 | public function formatRequest(RequestInterface $request): string 48 | { 49 | return $this->removeCredentials( 50 | $this->formatter->formatRequest($request) 51 | ); 52 | } 53 | 54 | /** @psalm-suppress DeprecatedMethod */ 55 | public function formatResponse(ResponseInterface $response): string 56 | { 57 | return $this->removeCredentials( 58 | $this->formatter->formatResponse($response) 59 | ); 60 | } 61 | 62 | public function formatResponseForRequest(ResponseInterface $response, RequestInterface $request): string 63 | { 64 | if (!method_exists($this->formatter, 'formatResponseForRequest')) { 65 | return $this->formatResponse($response); 66 | } 67 | 68 | /** 69 | * @psalm-suppress UnnecessaryVarAnnotation 70 | * 71 | * @psalm-var string $formatted 72 | */ 73 | $formatted = $this->formatter->formatResponseForRequest($response, $request); 74 | 75 | return $this->removeCredentials($formatted); 76 | } 77 | 78 | private function removeCredentials(string $info): string 79 | { 80 | return array_reduce( 81 | $this->sensitiveHeaders, 82 | /** @psalm-suppress InvalidReturnStatement, InvalidReturnType */ 83 | fn (string $sensitiveData, string $header): string => Regex\replace( 84 | $sensitiveData, 85 | '{^('.preg_quote($header, '{').')\:(.*)}im', 86 | '$1: xxxx', 87 | ), 88 | $info 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Formatter/RemoveSensitiveJsonKeysFormatter.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $sensitiveJsonKeys; 27 | 28 | /** 29 | * @param non-empty-list $sensitiveJsonKeys 30 | */ 31 | public function __construct(HttpFormatter $formatter, array $sensitiveJsonKeys) 32 | { 33 | $this->formatter = $formatter; 34 | $this->sensitiveJsonKeys = $sensitiveJsonKeys; 35 | } 36 | 37 | /** 38 | * @param non-empty-list $sensitiveJsonKeys 39 | * 40 | * @return Decorator 41 | */ 42 | public static function createDecorator(array $sensitiveJsonKeys): Closure 43 | { 44 | return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveJsonKeys); 45 | } 46 | 47 | public function formatRequest(RequestInterface $request): string 48 | { 49 | return $this->removeCredentials( 50 | $this->formatter->formatRequest($request) 51 | ); 52 | } 53 | 54 | /** @psalm-suppress DeprecatedMethod */ 55 | public function formatResponse(ResponseInterface $response): string 56 | { 57 | return $this->removeCredentials( 58 | $this->formatter->formatResponse($response) 59 | ); 60 | } 61 | 62 | public function formatResponseForRequest(ResponseInterface $response, RequestInterface $request): string 63 | { 64 | if (!method_exists($this->formatter, 'formatResponseForRequest')) { 65 | return $this->formatResponse($response); 66 | } 67 | 68 | /** 69 | * @psalm-suppress UnnecessaryVarAnnotation 70 | * 71 | * @psalm-var string $formatted 72 | */ 73 | $formatted = $this->formatter->formatResponseForRequest($response, $request); 74 | 75 | return $this->removeCredentials($formatted); 76 | } 77 | 78 | private function removeCredentials(string $info): string 79 | { 80 | return array_reduce( 81 | $this->sensitiveJsonKeys, 82 | /** @psalm-suppress InvalidReturnStatement, InvalidReturnType */ 83 | fn (string $sensitiveData, string $jsonKey): string => Regex\replace( 84 | $sensitiveData, 85 | '{"('.preg_quote($jsonKey, '{').')"\:\s*"([^"]*)"}i', 86 | '"$1": "xxxx"', 87 | ), 88 | $info 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /docs/sdk.md: -------------------------------------------------------------------------------- 1 | # Creating a Software Development kit for API's 2 | 3 | If your API is very straight forward, you might not want to create request handlers for every action. 4 | You could for example create a more classic API with this package as well. 5 | 6 | We provide some tools to compose straight-forward Rest HTTP resources. 7 | Here is an example for a REST service: 8 | 9 | ```php 10 | use Phpro\HttpTools\Sdk\HttpResource; 11 | use Phpro\HttpTools\Sdk\Rest; 12 | use Phpro\HttpTools\Request\Request; 13 | 14 | /** 15 | * @template ResultType 16 | * @extends HttpResource 17 | */ 18 | final class UsersResouce extends HttpResource 19 | { 20 | use Rest\CreateTrait; 21 | use Rest\FindTrait; 22 | use Rest\GetTrait; 23 | use Rest\UpdateTrait; 24 | use Rest\PatchTrait; 25 | use Rest\DeleteTrait; 26 | 27 | protected function path(): string 28 | { 29 | return '/users'; 30 | } 31 | 32 | public function me() 33 | { 34 | $request = new Request('GET', '/user', [], null); 35 | return $this->transport()($request); 36 | } 37 | } 38 | ``` 39 | 40 | You could wrap multiple resources in an SDK client like this: 41 | 42 | ```php 43 | 44 | use Phpro\HttpTools\Transport\TransportInterface; 45 | 46 | /** 47 | * @template ResultType 48 | */ 49 | final class MyClient 50 | { 51 | /** 52 | * @var UsersResource 53 | * @psalm-readonly 54 | */ 55 | public $users; 56 | 57 | /** 58 | * @param TransportInterface $transport 59 | */ 60 | public function __construct(TransportInterface $transport) 61 | { 62 | $this->users = new UsersResource($transport); 63 | } 64 | } 65 | ``` 66 | 67 | Example usage: 68 | 69 | ```php 70 | /** @var TransportInterface $transport */ 71 | /** @var MyClient $sdk */ 72 | $sdk = new MyClient($transport); 73 | 74 | var_dump($sdk->users->find('veewee')); 75 | ``` 76 | 77 | You could even swap the transport with one that supports a different output type and still keep type-safety! 78 | 79 | > **Note**: If you are using the serializer transport, you might want to set the output types from within the Client. 80 | > You might also want to create a separate function for delete and find. 81 | 82 | 83 | # Built-in traits: 84 | 85 | These are some very opinionated traits that can be used to build your SDK. 86 | If you want to change something for your specific case, you can implement your own method or trait. 87 | 88 | | Trait | Description | 89 | | --- | --- | 90 | | CreateTrait | Can be used to create a new resource from an array | 91 | | DeleteTrait | Can be used to delete a resource from an identifier | 92 | | FindTrait | Can be used to find a resource with an identifier | 93 | | GetTrait | Can be used to list all resources | 94 | | PatchTrait | Can be used to patch a resource with an identifier from an array | 95 | | UpdateTrait | Can be used to update a resource with an identifier from an array | 96 | 97 | -------------------------------------------------------------------------------- /docs/files.md: -------------------------------------------------------------------------------- 1 | # Dealing with files 2 | 3 | ## Download file 4 | 5 | For downloading files, you can use the `BinaryDecoder` - which has its own `BinaryDownloadPreset`: 6 | 7 | ```php 8 | use Phpro\HttpTools\Encoding\Binary\BinaryFile; 9 | use Phpro\HttpTools\Request\Request; 10 | use Phpro\HttpTools\Transport\Presets\BinaryDownloadPreset; 11 | 12 | $transport = BinaryDownloadPreset::create($client, $uriBuilder); 13 | $binaryFile = $transport( 14 | new Request('GET', '/some/file', [], null), 15 | ); 16 | 17 | assert($binaryFile instanceof BinaryFile); 18 | var_dump( 19 | $binaryFile->stream(), 20 | $binaryFile->fileSizeInBytes(), 21 | $binaryFile->hash(), 22 | $binaryFile->extension(), 23 | $binaryFile->mimeType(), 24 | $binaryFile->fileName(), 25 | ); 26 | ``` 27 | 28 | This decoder will try to parse some information from the Response headers and stream: 29 | 30 | * The stream body can be used to tell the size in bytes of the response or to calculate an MD5 (or other algorithm's) hash. 31 | * `Content-Type`: could be used to guess the mime-type 32 | * `Content-Disposistion`: could result in guessing the filename, extension and/or mime-type. 33 | * `Content-Length`: could be used to guess the size in bytes of the response. 34 | 35 | The decoder is highly configurable, meaning you can alter the way it guesses specific properties or opt-out on intenser logic like calculating a md5 hash. 36 | 37 | 38 | ## Upload file(s) 39 | 40 | If you have to upload files to an API server, you could use the `MultiPartEncoder` which uses `symfony/mime` internally. 41 | An example setup could look like this: 42 | 43 | ```php 44 | use Phpro\HttpTools\Encoding\Json\JsonDecoder; 45 | use Phpro\HttpTools\Encoding\Mime\MultiPartEncoder; 46 | use Phpro\HttpTools\Transport\EncodedTransportFactory; 47 | use Symfony\Component\Mime\Part\DataPart; 48 | use Symfony\Component\Mime\Part\Multipart\FormDataPart; 49 | use Phpro\HttpTools\Request\Request; 50 | 51 | $transport = EncodedTransportFactory::create( 52 | $client, 53 | $uriBuilder, 54 | MultiPartEncoder::createWithAutodiscoveredPsrFactories(), 55 | JsonDecoder::createWithAutodiscoveredPsrFactories() 56 | ); 57 | 58 | $jsonData = $transport( 59 | new Request('POST', '/some/file', [], new FormDataPart([ 60 | 'name' => 'Jos bos', 61 | 'profile-pic' => DataPart::fromPath('/my-profile-pic.jpg') 62 | ])), 63 | ); 64 | ``` 65 | 66 | **Note:** If you wish not to use `symfony/mime` for uploading files, you could use `guzzle/psr7`'s `MultipartStream` with the existing `StreamEncoder` option: 67 | 68 | ```php 69 | use GuzzleHttp\Psr7\MultipartStream; 70 | use Phpro\HttpTools\Encoding\Json\JsonDecoder; 71 | use Phpro\HttpTools\Encoding\Stream\StreamEncoder; 72 | use Phpro\HttpTools\Transport\EncodedTransportFactory; 73 | use Symfony\Component\Mime\Part\DataPart; 74 | use Symfony\Component\Mime\Part\Multipart\FormDataPart; 75 | use Phpro\HttpTools\Request\Request; 76 | 77 | $transport = EncodedTransportFactory::create( 78 | $client, 79 | $uriBuilder, 80 | StreamEncoder::createWithAutodiscoveredPsrFactories(), 81 | JsonDecoder::createWithAutodiscoveredPsrFactories() 82 | ); 83 | 84 | $multiPartStream = new MultipartStream($parts) 85 | $jsonData = $transport( 86 | new Request('POST', '/some/file', [], $multiPartStream)), 87 | ); 88 | ``` 89 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpro/http-tools", 3 | "description": "HTTP tools for developing more consistent HTTP implementations.", 4 | "keywords": ["PSR-7", "PSR-18", "PSR-17", "HTTP", "Client"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Toon Verwerft", 10 | "email": "toon.verwerft@phpro.be" 11 | }, 12 | { 13 | "name": "Jelle Deneweth", 14 | "email": "jelle.deneweth@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "~8.3.0 || ~8.4.0 || ~8.5.0", 19 | "ext-json": "*", 20 | "azjezz/psl": "^3.1 || ^4.0", 21 | "cardinalby/content-disposition": "^1.1", 22 | "league/uri": "^7.3", 23 | "php-http/client-common": "^2.7", 24 | "php-http/discovery": "^1.19", 25 | "php-http/httplug": "^2.4", 26 | "php-http/logger-plugin": "^1.3", 27 | "php-http/message": "^1.16 || ^2.0", 28 | "psr/http-client-implementation": "^1.0", 29 | "psr/http-factory": "^1.0", 30 | "psr/http-factory-implementation": "^1.0", 31 | "psr/http-message": "^1.0 || ^2.0", 32 | "psr/http-message-implementation": "^1.0", 33 | "psr/log": "^3", 34 | "webmozart/assert": "^1.11" 35 | }, 36 | "require-dev": { 37 | "guzzlehttp/guzzle": "^7.8", 38 | "nyholm/psr7": "^1.8", 39 | "php-cs-fixer/shim": "~3.88", 40 | "php-http/message-factory": "1.1.0", 41 | "php-http/mock-client": "^1.6", 42 | "php-http/vcr-plugin": "^1.2", 43 | "phpro/grumphp-shim": "^2.17", 44 | "phpunit/phpunit": "~12.4", 45 | "symfony/http-client": "^5.4.26 || ^6.0 || ^7.0", 46 | "symfony/mime": "^6.0 || ^7.0", 47 | "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", 48 | "symfony/property-access": "^5.4 || ^6.0 || ^7.0", 49 | "symfony/serializer": "^5.4 || ^6.0 || ^7.0", 50 | "vimeo/psalm": "~6.13" 51 | }, 52 | "suggest": { 53 | "symfony/http-client": "If you want to use the built-in symfony/http-client tools.", 54 | "symfony/serializer": "If you want to use symfony serializer to handle request serialization and response deserialization.", 55 | "symfony/mime": "If you want to use symfony/mime to upload or download binary files.", 56 | "guzzlehttp/guzzle": "If you want to use the built-in guzzlehttp/guzzle tools.", 57 | "php-http/mock-client": "For testing HTTP clients through mocking Requests and responses.", 58 | "php-http/vcr-plugin": "For testing HTTP clients through storing and replaying requests and responses." 59 | }, 60 | "config": { 61 | "sort-packages": true, 62 | "allow-plugins": { 63 | "phpro/grumphp-shim": true, 64 | "php-http/discovery": true 65 | } 66 | }, 67 | "autoload": { 68 | "psr-4": { 69 | "Phpro\\HttpTools\\": "src" 70 | } 71 | }, 72 | "autoload-dev": { 73 | "psr-4": { 74 | "Phpro\\HttpTools\\Tests\\": "tests" 75 | } 76 | }, 77 | "scripts": { 78 | "functional-testserver": [ 79 | "Composer\\Config::disableProcessTimeout", 80 | "php -S 127.0.0.1:8000 -t tests/Fixtures/functional/server" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setFinder( 5 | PhpCsFixer\Finder::create() 6 | ->in([ 7 | __DIR__ . '/examples', 8 | __DIR__ . '/src', 9 | __DIR__ . '/tests', 10 | ]) 11 | ->name('*.php') 12 | ) 13 | ->setRiskyAllowed(true) 14 | ->setRules([ 15 | '@Symfony' => true, 16 | 'align_multiline_comment' => true, 17 | 'array_indentation' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'backtick_to_shell_exec' => true, 20 | 'blank_line_before_statement' => ['statements' => ['return']], 21 | 'class_keyword_remove' => false, 22 | 'combine_consecutive_issets' => true, 23 | 'combine_consecutive_unsets' => true, 24 | 'comment_to_phpdoc' => true, 25 | 'compact_nullable_typehint' => true, 26 | 'date_time_immutable' => true, 27 | 'declare_strict_types' => true, 28 | 'doctrine_annotation_array_assignment' => true, 29 | 'doctrine_annotation_braces' => true, 30 | 'doctrine_annotation_indentation' => true, 31 | 'doctrine_annotation_spaces' => true, 32 | 'escape_implicit_backslashes' => true, 33 | 'explicit_indirect_variable' => true, 34 | 'explicit_string_variable' => true, 35 | 'final_internal_class' => true, 36 | 'final_class' => true, 37 | 'fully_qualified_strict_types' => true, 38 | 'general_phpdoc_annotation_remove' => false, 39 | 'header_comment' => false, 40 | 'heredoc_to_nowdoc' => false, 41 | 'linebreak_after_opening_tag' => true, 42 | 'list_syntax' => ['syntax' => 'short'], 43 | 'mb_str_functions' => true, 44 | 'method_chaining_indentation' => true, 45 | 'multiline_comment_opening_closing' => true, 46 | 'multiline_whitespace_before_semicolons' => true, 47 | 'native_function_invocation' => false, 48 | 'no_alternative_syntax' => true, 49 | 'no_blank_lines_before_namespace' => false, 50 | 'no_null_property_initialization' => true, 51 | 'no_php4_constructor' => true, 52 | 'echo_tag_syntax' => ['format'=> 'long'], 53 | 'no_superfluous_elseif' => true, 54 | 'no_unreachable_default_argument_value' => true, 55 | 'no_useless_else' => true, 56 | 'no_useless_return' => true, 57 | 'not_operator_with_space' => false, 58 | 'not_operator_with_successor_space' => false, 59 | 'ordered_class_elements' => false, 60 | 'ordered_imports' => true, 61 | 'php_unit_dedicate_assert' => false, 62 | 'php_unit_expectation' => false, 63 | 'php_unit_mock' => false, 64 | 'php_unit_namespaced' => false, 65 | 'php_unit_no_expectation_annotation' => false, 66 | 'phpdoc_order_by_value' => ['annotations' => ['covers']], 67 | 'php_unit_set_up_tear_down_visibility' => true, 68 | 'php_unit_strict' => false, 69 | 'php_unit_test_annotation' => false, 70 | 'php_unit_test_class_requires_covers' => false, 71 | 'php_unit_method_casing' => ['case' => 'snake_case'], 72 | 'phpdoc_add_missing_param_annotation' => true, 73 | 'phpdoc_order' => true, 74 | 'phpdoc_types_order' => ['null_adjustment' => 'always_last'], 75 | 'pow_to_exponentiation' => true, 76 | 'psr_autoloading' => ['dir' => 'src'], 77 | 'random_api_migration' => false, 78 | 'simplified_null_return' => true, 79 | 'static_lambda' => false, 80 | 'strict_comparison' => true, 81 | 'strict_param' => true, 82 | 'string_line_ending' => true, 83 | 'ternary_to_null_coalescing' => true, 84 | 'void_return' => true, 85 | 'yoda_style' => true, 86 | 'single_line_throw' => false, 87 | 'phpdoc_align' => ['align' => 'left'], 88 | 'phpdoc_to_comment' => false, 89 | 'global_namespace_import' => [ 90 | 'import_classes' => true, 91 | 'import_constants' => true, 92 | 'import_functions' => true, 93 | ], 94 | ]) 95 | ; 96 | -------------------------------------------------------------------------------- /src/Client/ClientBuilder.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | private SplPriorityQueue $plugins; 44 | /** 45 | * @var list 46 | */ 47 | private array $decorators = []; 48 | 49 | /** 50 | * @param iterable $middlewares 51 | */ 52 | public function __construct( 53 | ?ClientInterface $client = null, 54 | iterable $middlewares = [], 55 | ) { 56 | $this->client = $client ?? Psr18ClientDiscovery::find(); 57 | /** @var SplPriorityQueue $plugins */ 58 | $plugins = new SplPriorityQueue(); 59 | $this->plugins = $plugins; 60 | 61 | foreach ($middlewares as $middleware) { 62 | $plugins->insert($middleware, self::PRIORITY_LEVEL_DEFAULT); 63 | } 64 | } 65 | 66 | public static function default( 67 | ?ClientInterface $client = null, 68 | ): self { 69 | return new self($client, [ 70 | new Plugin\ErrorPlugin(), 71 | ]); 72 | } 73 | 74 | /** 75 | * @param Decorator $decorator 76 | */ 77 | public function addDecorator(Closure $decorator): self 78 | { 79 | $this->decorators[] = $decorator; 80 | 81 | return $this; 82 | } 83 | 84 | public function addPlugin( 85 | Plugin $plugin, 86 | int $priority = self::PRIORITY_LEVEL_DEFAULT, 87 | ): self { 88 | $this->plugins->insert($plugin, $priority); 89 | 90 | return $this; 91 | } 92 | 93 | /** 94 | * @param Closure(ClientInterface): Plugin $pluginBuilder 95 | */ 96 | public function addPluginWithCurrentlyConfiguredClient( 97 | Closure $pluginBuilder, 98 | int $priority = self::PRIORITY_LEVEL_DEFAULT, 99 | ): self { 100 | return $this->addPlugin( 101 | $pluginBuilder($this->build()), 102 | $priority, 103 | ); 104 | } 105 | 106 | public function addAuthentication( 107 | Authentication $authentication, 108 | int $priority = self::PRIORITY_LEVEL_SECURITY, 109 | ): self { 110 | return $this->addPlugin(new Plugin\AuthenticationPlugin($authentication), $priority); 111 | } 112 | 113 | public function addLogger( 114 | LoggerInterface $logger, 115 | ?Formatter $formatter = null, 116 | int $priority = self::PRIORITY_LEVEL_LOGGING, 117 | ): self { 118 | return $this->addPlugin(new Plugin\LoggerPlugin($logger, $formatter), $priority); 119 | } 120 | 121 | /** 122 | * @param array $headers 123 | * 124 | * @return $this 125 | */ 126 | public function addHeaders( 127 | array $headers, 128 | int $priority = self::PRIORITY_LEVEL_DEFAULT, 129 | ): self { 130 | return $this->addPlugin(new Plugin\HeaderSetPlugin($headers), $priority); 131 | } 132 | 133 | public function addBaseUri( 134 | UriInterface|string $baseUri, 135 | bool $replaceHost = true, 136 | int $priority = self::PRIORITY_LEVEL_DEFAULT, 137 | ): self { 138 | $baseUri = match (true) { 139 | is_string($baseUri) => Psr17FactoryDiscovery::findUriFactory()->createUri($baseUri), 140 | default => $baseUri, 141 | }; 142 | 143 | return $this->addPlugin(new Plugin\BaseUriPlugin($baseUri, ['replace' => $replaceHost]), $priority); 144 | } 145 | 146 | public function addRecording( 147 | NamingStrategyInterface $namingStrategy, 148 | RecorderInterface&PlayerInterface $recorder, 149 | int $priority = self::PRIORITY_LEVEL_LOGGING, 150 | ): self { 151 | return $this 152 | ->addPlugin(new RecordPlugin($namingStrategy, $recorder), $priority) 153 | ->addPlugin(new ReplayPlugin($namingStrategy, $recorder, false), $priority); 154 | } 155 | 156 | /** 157 | * @param PluginCallback $callback 158 | */ 159 | public function addCallback(callable $callback, int $priority = self::PRIORITY_LEVEL_DEFAULT): self 160 | { 161 | return $this->addPlugin(new CallbackPlugin($callback), $priority); 162 | } 163 | 164 | public function build(): PluginClient 165 | { 166 | return PluginsConfigurator::configure( 167 | Fun\pipe(...$this->decorators)($this->client), 168 | [...clone $this->plugins] 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /docs/transports.md: -------------------------------------------------------------------------------- 1 | # Transports 2 | 3 | Transports are responsible for transforming a Request model into a PSR-7 HTTP request and asks a response through the actual HTTP client. 4 | Since the data inside the request and response value objects is set-up in a flexible way, we also provide a lot of ways of transforming the value objects into PSR-7 HTTP Requests and Responses. 5 | 6 | This is done by providing an encoding layer. 7 | 8 | ## Encoding 9 | 10 | Encoding is split up into `Encoder` and `Decoder` classes. 11 | The Encoder classes are responsible for converting the body of a Request value object into the body payload of a PSR-7 Http request. 12 | The Decoder classes are responsible for converting the payload of a PSR-7 Response object into a result that can be used by the transport or request handler. 13 | 14 | By splitting the Encoding into 2 components, you can compose any encoding component in a transport. 15 | Examples: 16 | 17 | 18 | | Transport | Encoder | Decoder | 19 | | --- | --- | --- | 20 | | `TransportInterface` | `JsonEncoder` | `JsonDecoder` | 21 | | `TransportInterface` | `RawEncoder` | `RawStringEncoder` | 22 | | `TransportInterface` | `JsonEncoder` | `RawStringEncoder` | 23 | | `TransportInterface` | `EmptyBodyEncoder` | `RawStringEncoder` | 24 | 25 | 26 | ### Built-in encodings 27 | 28 | This package contains some frequently used encoders / decoders for you: 29 | 30 | | Class | EncodingType | Action | 31 | |-------------------------|---------------------------------------|-------------------------------------------------------------------------------------| 32 | | `EmptyBodyEncoder` | `EncoderInterface` | Creates empty request body | 33 | | `BinaryFileDecoder` | `DecoderInterface` | Parses file information from the HTTP response and returns a `BinaryFile` DTO | 34 | | `FormUrlencodedEncoder` | `EncoderInterface` | Adds form urlencoded body and headers to request | 35 | | `FormUrlencodedDecoder` | `DecoderInterface` | Converts form urlencoded response body to array | 36 | | `JsonEncoder` | `EncoderInterface` | Adds json body and headers to request | 37 | | `JsonDecoder` | `DecoderInterface` | Converts json response body to array | 38 | | `MultiPartEncoder` | `EncoderInterface` | Adds symfony/mime `AbstractMultipartPart`as HTTP body. Handy for form data + files. | 39 | | `StreamEncoder` | `EncoderInterface` | Adds PSR-7 Stream as request body | 40 | | `StreamDecoder` | `DecoderInterface` | Returns the PSR-7 Stream as response result | 41 | | `RawEncoder` | `EncoderInterface` | Adds raw string as request body | 42 | | `RawDecoder` | `DecoderInterface` | Returns the raw PSR-7 body string as response result | 43 | | `ResponseDecoder` | `DecoderInterface` | Returns the received PSR-7 response as result | 44 | 45 | ## Built-in transport presets: 46 | 47 | We've composed some of the encodings above into pre-configured transports: 48 | 49 | 50 | | Preset | RequestType | ResponseType | Factory method | 51 | |------------------------|-------------------------|---------------------|------------------------| 52 | | `BinaryDownloadPreset` | `null` | `BinaryFile` | `withEmptyRequest` | 53 | | `BinaryDownloadPreset` | `AbstractMultipartPart` | `BinaryFile` | `withMultiPartRequest` | 54 | | `FormUrlencodedPreset` | `?array` | `array` | `create` | 55 | | `JsonPreset` | `?array` | `array` | `create` | 56 | | `PsrPreset` | `string` | `ResponseInterface` | `create` | 57 | | `RawPreset` | `string` | `string` | `create` | 58 | 59 | ## Creating your own configuration 60 | 61 | We provide an `EncodedTransport` class that helps you build your own configuration. 62 | This transport takes a configurable encoder and decoder: 63 | 64 | 65 | ```php 66 | use Phpro\HttpTools\Transport\EncodedTransportFactory; 67 | 68 | EncodedTransportFactory::create( 69 | $client, 70 | $uriBuilder, 71 | $encoder, 72 | $decoder 73 | ); 74 | ``` 75 | 76 | ## Other transports 77 | 78 | ### Files 79 | 80 | We provide encoders and decoders for uploading and downloading files. 81 | 82 | [You can learn more about this on this dedicated page.](./files.md) 83 | 84 | ### SerializerTransport 85 | 86 | This transport allows you to use an external serializer to handle request serialization and response deserialization. 87 | You can use the symfony/serializer component or any other serializer you please. 88 | 89 | However, you do need to specify what output type the transport will deserialize to. (e.g. inside a request handler) 90 | 91 | ```php 92 | use Phpro\HttpTools\Serializer\SymfonySerializer; 93 | use Phpro\HttpTools\Transport\Presets\RawPreset; 94 | use Phpro\HttpTools\Transport\Serializer\SerializerTransport; 95 | 96 | $transport = new SerializerTransport( 97 | new SymfonySerializer($theSymfonySerializer, 'json'), 98 | RawPreset::create($client, $uriBuilder) 99 | ); 100 | 101 | $transport->withOutputType(SomeResponse::class); 102 | ``` 103 | 104 | If you want to use symfony/validator, you might need to: 105 | 106 | ```bash 107 | composer require symfony/serializer 108 | ``` 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Github Actions](https://github.com/phpro/http-tools/workflows/GrumPHP/badge.svg?branch=master) 2 | [![Installs](https://img.shields.io/packagist/dt/phpro/http-tools.svg)](https://packagist.org/packages/phpro/http-tools/stats) 3 | [![Packagist](https://img.shields.io/packagist/v/phpro/http-tools.svg)](https://packagist.org/packages/phpro/http-tools) 4 | 5 | 6 | # HTTP-Tools 7 | 8 | The goal of this package is to provide you some tools to set-up a data-centric, consistent HTTP integration. 9 | The HTTP client implementation you want to use is just a small implementation detail and doesn't matter. 10 | However, here are some default guidelines: 11 | 12 | ## Prerequisites 13 | 14 | Choosing what HTTP package you want to us is all up to you. 15 | We do require a PSR implementations in order to install this package: 16 | 17 | * PSR-7: `psr/http-message-implementation` like `nyholm/psr7` or `guzzlehttp/psr7` 18 | * PSR-17: `psr/http-factory-implementation` like `nyholm/psr7` or `guzzlehttp/psr7` 19 | * PSR-18: `psr/http-client-implementation` like `symfony/http-client` or `guzzlehttp/guzzle` 20 | 21 | ## Installation 22 | 23 | ```bash 24 | composer require phpro/http-tools 25 | ``` 26 | 27 | ## Setting up an HTTP client : 28 | 29 | You can choose whatever HTTP client you want. 30 | However, this package provides some convenient factories that make the configuration a bit easier. 31 | 32 | The factory accepts a list of implementation specific plugins / middlewares. 33 | Besides that, you can configure implementation-specific options like a base_uri or default headers. 34 | 35 | ```php 36 | $_ENV['SOME_CLIENT_BASE_URI']]; 44 | 45 | $httpClient = AutoDiscoveredClientFactory::create($middlewares); 46 | $httpClient = GuzzleClientFactory::create($guzzlePlugins, $options); 47 | $httpClient = SymfonyClientFactory::create($middlewares, $options); 48 | 49 | // If you are using guzzle, you can both use guzzle and httplug plugins. 50 | // You can wrap additional httplug plugins like this: 51 | $httpClient = PluginsConfigurator::configure($httpClient, $middlewares); 52 | ``` 53 | 54 | You can always create your own factory if you want to have more control or want to use another tool! 55 | 56 | **Note:** This package does not download a specific HTTP implementation. You can choose whatever package you want, but you'll have to manually add it to composer. 57 | 58 | ### Configuring the client through plugins 59 | 60 | [Plugin](http://docs.php-http.org/en/latest/plugins/index.html)-[Middleware](http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html) : Patato-Patato. 61 | 62 | If you want to extend how an HTTP client works, we want you to use plugins! 63 | You can use plugins for everything: logging, authentication, language specification, ... 64 | 65 | Examples: 66 | 67 | ```php 68 | addBaseUri('https://www.google.com') 96 | ->addHeaders([ 97 | 'x-Foo' => 'bar', 98 | ]) 99 | ->addAuthentication(new BasicAuth('user', 'pass')) 100 | ->addPlugin(new YourPlugin(), priority: 99) 101 | // -> and many more ... 102 | ->build(); 103 | ``` 104 | 105 | Our suggested approach is to create a configuration object for all variable client configurations and services. 106 | This can be used in combination with a PHP factory class that builds your client through this builder: 107 | 108 | ```php 109 | final readonly class YourClientConfig { 110 | public function __construct( 111 | public string $apiUrl, 112 | #[\SensitiveParameter] public string $apiKey, 113 | public LoggerInterface $logger 114 | ) { 115 | } 116 | } 117 | ``` 118 | 119 | ```php 120 | final readonly class YourClientFactory 121 | { 122 | public static function create(YourClientConfig $config): ClientInterface 123 | { 124 | return ClientBuilder::default() 125 | ->addBaseUri($config->apiUrl) 126 | ->addHeaders(['x-Api-Key' => $config->apiKey]) 127 | ->build(); 128 | } 129 | } 130 | ``` 131 | 132 | ### Logging 133 | 134 | This package contains the `php-http/logger-plugin`. 135 | On top of that, we've added some decorators that help you strip out sensitive information from the logs. 136 | You can switch from full to simple logging by specifying a debug parameter! 137 | 138 | ```php 139 | addLogger( 174 | new ConsoleLogger(new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG)), 175 | FormatterBuilder::default() 176 | ->withDebug(true) 177 | ->withMaxBodyLength(1000) 178 | ->addDecorator(RemoveSensitiveHeadersFormatter::createDecorator([ 179 | 'X-SENSITIVE-HEADER', 180 | ])) 181 | ->build() 182 | ) 183 | ->build(); 184 | ``` 185 | 186 | 187 | [More info ...](http://docs.php-http.org/en/latest/plugins/logger.html) 188 | 189 | ## Using the HTTP-Client 190 | 191 | We don't want you to use the PSR-18 client directly! Instead, we suggest you to use a request handler principle. 192 | So what does this architecture look like? 193 | 194 | ![Architecture](docs/assets/request-handlers.png) 195 | 196 | * **Models**: Request / Response value objects that can be used as wrapper around the outgoging or incoming data (arrays, objects, strings, ...). 197 | * **RequestHandler**: Transform a request data model into a response data model by using a transport. You could add error handling in there as well. 198 | * **Transport**: Transforms a Request data model into a PSR-7 HTTP request and asks for a response through the actual HTTP client. 199 | * **Encoding**: A transport can take encoders / decoders that are responsible for converting the value objects data to e.g. JSON payloads and visa versa. 200 | * **HTTP-Client**: Whichever PSR-18 HTTP client you want to use: guzzle, curl, symfony/http-client, ... 201 | 202 | [More information on Transports and Encodings](docs/transports.md) 203 | 204 | By using this architecture, we provide an easy to extend flow with models that replace cumbersome array structures. 205 | 206 | 207 | You might be familiar with 1 "client" class that provides access to multiple API endpoints. We see that approach as a multi-requesthandler class. 208 | You are free to choose that approach, owever, we suggest using 1 request handler per API endpoint. 209 | This way, you can inject / mock only the things you require at that moment into your codebase. 210 | 211 | Example implementation: 212 | 213 | ```php 214 | 238 | */ 239 | private TransportInterface $transport 240 | ) {} 241 | 242 | public function __invoke(ListRequest $request): ListResponse 243 | { 244 | // You could validate the result first + throw exceptions based on invalid content 245 | // Tip : never trust APIs! 246 | // Try to gracefully fall back if possible and keep an eye on how the implementation needs to handle errors! 247 | 248 | return ListResponse::fromRawArray( 249 | ($this->transport)($request) 250 | ); 251 | } 252 | } 253 | ``` 254 | 255 | ```php 256 | 265 | */ 266 | class ListRequest implements RequestInterface 267 | { 268 | public function method() : string 269 | { 270 | return 'GET'; 271 | } 272 | 273 | public function uri() : string 274 | { 275 | return '/list{?query}'; 276 | } 277 | 278 | public function uriParameters() : array 279 | { 280 | return [ 281 | 'query' => 'somequery', 282 | ]; 283 | } 284 | 285 | public function body() : array 286 | { 287 | return []; 288 | } 289 | } 290 | 291 | // By wrapping the response in a Value Object, you can sanitize and normalize data. 292 | // You could as well lazilly throw an exception in here if some value is missing. 293 | // However, that's might be more of a task for a request-handler. 294 | 295 | class ListResponse 296 | { 297 | public static function fromRawArray(array $data): self 298 | { 299 | return new self($data); 300 | } 301 | 302 | public function getItems(): array 303 | { 304 | // Never trust APIs! 305 | return (array) ($this->data['items'] ?? []); 306 | } 307 | } 308 | ``` 309 | 310 | This example is rather easy and might seem like overkill at first. 311 | The true power will be visible once you create multiple named constructors and conditional porperty accessors inside the request models. 312 | The response models, if crafted carefully, will improve the stability of your integration! 313 | 314 | ## Async request handlers 315 | 316 | In order to send async requests, you can use this package in combination with fiber-based PSR-18 clients. 317 | The architecture can remain as is. 318 | 319 | An example client based on ReactPHP might be based on this: 320 | 321 | ```sh 322 | composer require react/async veewee/psr18-react-browser 323 | ``` 324 | 325 | *(There currently is no official fiber based PSR-18 implementation of either AMP or ReactPHP. Therefore, [a small bridge can be used intermediately](https://github.com/veewee/psr18-react-browser))* 326 | 327 | Since fibers deal with the async part, you can write your Request handlers is if they were synchronous: 328 | 329 | ```php 330 | 339 | */ 340 | private TransportInterface $transport 341 | ) {} 342 | 343 | public function __invoke(FetchRequest $request): Something 344 | { 345 | return Something::tryParse( 346 | ($this->transport)($data) 347 | ); 348 | } 349 | } 350 | ``` 351 | 352 | In order to fetch multiple simultaneous requests, you can execute these in parallel: 353 | 354 | ```php 355 | use Phpro\HttpTools\Transport\Presets\JsonPreset; 356 | use Phpro\HttpTools\Uri\RawUriBuilder; 357 | use Phpro\HttpTools\Uri\TemplatedUriBuilder; 358 | use Veewee\Psr18ReactBrowser\Psr18ReactBrowserClient; 359 | use function React\Async\async; 360 | use function React\Async\await; 361 | use function React\Async\parallel; 362 | 363 | $client = Psr18ReactBrowserClient::default(); 364 | $transport = JsonPreset::create($client, new TemplatedUriBuilder()); 365 | $handler = new FetchSomething($transport); 366 | 367 | $run = fn($id) => async(fn () => $handler(new FetchRequest($id))); 368 | $things = await(parallel([ 369 | $run(1), 370 | $run(2), 371 | $run(3), 372 | ])); 373 | ``` 374 | 375 | If your client is fiber compatible, this will fetch all requests in parallel. 376 | If your client is not fiber compatible, this will result in the requests being performed in series. 377 | 378 | ## SDK 379 | 380 | In some situations, writing request handlers might be overkill. 381 | This package also provides some tools to compose a more generic API client instead. 382 | However, our primary suggestion is to create specific request handlers instead! 383 | 384 | [More information on creating SDKs](docs/sdk.md) 385 | 386 | ## Testing HTTP clients 387 | 388 | This tool provided some traits for unit testing your API client with PHPUnit. 389 | 390 | ### UseHttpFactories 391 | 392 | This trait can help you build requests and responses inside your tests without worrying what HTTP package you use: 393 | 394 | * `createRequest` 395 | * `createResponse` 396 | * `createStream` 397 | * `createEmptyHttpClientException` 398 | 399 | ### UseHttpToolsFactories 400 | 401 | This trait can help you build HTTP tools specific objects in unit tests. 402 | It could be hande to e.g. test transports. 403 | 404 | * `createToolsRequest` 405 | 406 | Example: 407 | 408 | ```php 409 | $request = $this->createToolsRequest('GET', '/some-endpoint', [], ['hello' => 'world']); 410 | ``` 411 | 412 | ### UseMockClient 413 | 414 | *Includes `UseHttpFactories` trait* 415 | 416 | Preferably, this one will be used to test your own middlewares and transports. 417 | It is also possible to test a request-handler, but you'll have to manually provide the response for it. 418 | 419 | example: 420 | 421 | ```php 422 | client = $this->mockClient(function (Client $client): Client { 435 | $client->setDefaultException(new \Exception('Dont call me!')); 436 | return $client; 437 | }); 438 | } 439 | } 440 | ``` 441 | 442 | [More info ...](http://docs.php-http.org/en/latest/clients/mock-client.html) 443 | 444 | ### UseVcrClient 445 | 446 | *Includes `UseHttpFactories` trait* 447 | 448 | This one can be used to test your request-handlers with realtime data. 449 | The first you use it in your test, it will do the actual HTTP request. 450 | The response of this request will be recorded and stored inside your project. 451 | The second time the test runs, it will use the recorded version. 452 | 453 | example: 454 | 455 | ```php 456 | client = AutoDiscoveredClientFactory::create([ 470 | ...$this->useRecording(FIXTURES_DIR, new PathNamingStrategy()) 471 | ]); 472 | } 473 | } 474 | ``` 475 | 476 | [More info ...](http://docs.php-http.org/en/latest/plugins/vcr.html) 477 | --------------------------------------------------------------------------------