├── .gitignore ├── src ├── ServiceException.php ├── Http │ ├── Body.php │ ├── GET.php │ ├── PUT.php │ ├── HEAD.php │ ├── POST.php │ ├── DELETE.php │ ├── PATCH.php │ ├── OPTIONS.php │ ├── FieldValue.php │ ├── HeaderValue.php │ ├── Header.php │ └── HTTP.php ├── Call.php ├── Internal │ ├── Service.php │ └── ServiceCall.php ├── Interceptor │ └── RequireSuccessfulResponse.php └── ServiceFactory.php ├── .phpstorm.meta.php ├── composer.json ├── LICENSE ├── README.md └── examples └── github.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock -------------------------------------------------------------------------------- /src/ServiceException.php: -------------------------------------------------------------------------------- 1 | name; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Http/HeaderValue.php: -------------------------------------------------------------------------------- 1 | name; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Call.php: -------------------------------------------------------------------------------- 1 | name; 17 | } 18 | 19 | public function getValue(): string 20 | { 21 | return $this->value; 22 | } 23 | } -------------------------------------------------------------------------------- /src/Http/HTTP.php: -------------------------------------------------------------------------------- 1 | method = $method; 16 | $this->uri = $uri; 17 | } 18 | 19 | public function getUri(): string 20 | { 21 | return $this->uri; 22 | } 23 | 24 | public function getMethod(): string 25 | { 26 | return $this->method; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Internal/Service.php: -------------------------------------------------------------------------------- 1 | methods = $methods; 16 | } 17 | 18 | public function __call(string $name, array $arguments): Call 19 | { 20 | $serviceCall = $this->methods[\strtolower($name)] ?? throw new \BadMethodCallException('Unknown method: ' . $name); 21 | return $serviceCall->withArguments($arguments); 22 | } 23 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kelunik/aerial", 3 | "type": "library", 4 | "license": "MIT", 5 | "autoload": { 6 | "psr-4": { 7 | "Kelunik\\Aerial\\": "src" 8 | } 9 | }, 10 | "autoload-dev": { 11 | "psr-4": { 12 | "Kelunik\\Aerial\\": "test" 13 | } 14 | }, 15 | "authors": [ 16 | { 17 | "name": "Niklas Keller", 18 | "email": "me@kelunik.com" 19 | } 20 | ], 21 | "minimum-stability": "beta", 22 | "require": { 23 | "php": ">=8.1", 24 | "amphp/http-client": "^5-beta.4", 25 | "amphp/serialization": "^1.0", 26 | "cuyz/valinor": "^0.14.0", 27 | "ocramius/proxy-manager": "^2.14" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Interceptor/RequireSuccessfulResponse.php: -------------------------------------------------------------------------------- 1 | request($request, $cancellation); 17 | 18 | $status = $response->getStatus(); 19 | if ($status < 200 || $status >= 300) { 20 | throw new ServiceException('Invalid response status: ' . $status); 21 | } 22 | 23 | return $response; 24 | } 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Niklas Keller 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kelunik/aerial 2 | 3 | Build HTTP API clients using interface definitions. 4 | Builds on top of [Valinor](https://github.com/CuyZ/Valinor). 5 | It uses internal APIs, so use at your own risk for now. 6 | 7 | ```php 8 | interface GitHub 9 | { 10 | /** @return Call */ 11 | #[GET('/users{/user}')] 12 | public function getUser(string $user): Call; 13 | } 14 | 15 | final class GitHubUser 16 | { 17 | public string $login; 18 | public string $avatarUrl; 19 | public string $name; 20 | } 21 | ``` 22 | 23 | ```php 24 | $httpClient = (new HttpClientBuilder()) 25 | ->intercept(new ResolveBaseUri('https://api.github.com/')) 26 | ->intercept(new SetRequestHeader('user-agent', 'github.com/kelunik/aerial')) 27 | ->intercept(new RequireSuccessfulResponse()) 28 | ->build(); 29 | 30 | $serializer = new class implements Serializer { 31 | private Serializer $json; 32 | 33 | public function __construct() 34 | { 35 | $this->json = JsonSerializer::withAssociativeArrays(); 36 | } 37 | 38 | public function serialize($data): string 39 | { 40 | return $this->json->serialize($data); 41 | } 42 | 43 | public function unserialize(string $data): Source 44 | { 45 | return Source::array($this->json->unserialize($data))->camelCaseKeys(); 46 | } 47 | }; 48 | 49 | $github = (new ServiceFactory( 50 | $httpClient, 51 | $serializer, 52 | ))->build(GitHub::class); 53 | ``` 54 | 55 | ```php 56 | $call = $github->getUser('kelunik'); 57 | $user = $call->execute(); 58 | 59 | var_dump($user->login); 60 | var_dump($user->avatarUrl); 61 | var_dump($user->name); 62 | 63 | var_dump($call->getResponse()->getHeader('x-ratelimit-remaining')); 64 | ``` -------------------------------------------------------------------------------- /examples/github.php: -------------------------------------------------------------------------------- 1 | */ 19 | #[GET('/users{/user}')] 20 | public function getUser(string $user): Call; 21 | } 22 | 23 | final class GitHubUser 24 | { 25 | public string $login; 26 | public string $avatarUrl; 27 | public string $name; 28 | } 29 | 30 | $httpClient = (new HttpClientBuilder()) 31 | ->intercept(new ResolveBaseUri('https://api.github.com/')) 32 | ->intercept(new SetRequestHeader('user-agent', 'github.com/kelunik/aerial')) 33 | ->intercept(new RequireSuccessfulResponse()) 34 | ->build(); 35 | 36 | $serializer = new class implements Serializer { 37 | private Serializer $json; 38 | 39 | public function __construct() 40 | { 41 | $this->json = JsonSerializer::withAssociativeArrays(); 42 | } 43 | 44 | public function serialize($data): string 45 | { 46 | return $this->json->serialize($data); 47 | } 48 | 49 | public function unserialize(string $data): Source 50 | { 51 | return Source::array($this->json->unserialize($data))->camelCaseKeys(); 52 | } 53 | }; 54 | 55 | $github = (new ServiceFactory( 56 | $httpClient, 57 | $serializer, 58 | ))->build(GitHub::class); 59 | 60 | $call = $github->getUser('kelunik'); 61 | $user = $call->execute(); 62 | 63 | var_dump($user->login); 64 | var_dump($user->avatarUrl); 65 | var_dump($user->name); 66 | 67 | var_dump($call->getResponse()->getHeader('x-ratelimit-remaining')); -------------------------------------------------------------------------------- /src/ServiceFactory.php: -------------------------------------------------------------------------------- 1 | mapper ??= (new MapperBuilder())->flexible()->mapper(); 29 | 30 | $this->reflection = new ReflectionFunctionDefinitionRepository( 31 | new LexingTypeParserFactory(new BasicTemplateParser()), 32 | new CombinedAttributesRepository() 33 | ); 34 | 35 | $this->proxyFactory = new LazyLoadingValueHolderFactory(); 36 | } 37 | 38 | public function build(string $class): object 39 | { 40 | return $this->proxyFactory->createProxy( 41 | $class, 42 | function (&$wrappedObject, $proxy, $method, $parameters, &$initializer) use ($class) { 43 | $methods = []; 44 | 45 | $reflectionClass = new \ReflectionClass($class); 46 | foreach ($reflectionClass->getMethods() as $method) { 47 | $definition = $this->reflection->for($method->getClosure($proxy)); 48 | $methods[\strtolower($method->getName())] = new ServiceCall($definition, $this->httpClient, $this->serializer, $this->mapper); 49 | } 50 | 51 | $wrappedObject = new Service($methods); 52 | $initializer = null; // turning off further lazy initialization 53 | 54 | return true; // report success 55 | } 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /src/Internal/ServiceCall.php: -------------------------------------------------------------------------------- 1 | determineBodyType($this->definition->returnType()); 55 | $this->determineRequestTarget(); 56 | $this->determineHeaders(); 57 | } 58 | 59 | private function determineBodyType(Type $returnType): void 60 | { 61 | if ($returnType instanceof UnresolvableType) { 62 | throw new \TypeError('Unresolvable type: ' . $returnType->getMessage()); 63 | } 64 | 65 | if (!$returnType instanceof InterfaceType) { 66 | throw new \TypeError('Invalid return type, expected Kelunik\\Aerial\\Call, got ' . $returnType->toString()); 67 | } 68 | 69 | if ($returnType->className() !== Call::class) { 70 | throw new \TypeError('Invalid return type, expected Kelunik\\Aerial\\Call, got ' . $returnType->toString()); 71 | } 72 | 73 | $this->bodyType = $returnType->generics()['T']; 74 | } 75 | 76 | private function determineRequestTarget() 77 | { 78 | $http = self::attribute($this->definition, HTTP::class); 79 | if (!$http) { 80 | throw new ServiceException('Missing #[HTTP] attribute on ' . $this->definition->signature()); 81 | } 82 | 83 | $this->requestMethod = $http->getMethod(); 84 | $this->uriTemplate = new UriTemplate($http->getUri()); 85 | } 86 | 87 | private static function attribute( 88 | ParameterDefinition|FunctionDefinition $definition, 89 | string $attributeClass 90 | ): ?object { 91 | if (!$definition->attributes()->has($attributeClass)) { 92 | return null; 93 | } 94 | 95 | $attributes = $definition->attributes()->ofType($attributeClass); 96 | if (\count($attributes) !== 1) { 97 | throw new ServiceException(\sprintf( 98 | 'Invalid parameter %s, expected exactly one %s attribute', 99 | $definition->signature(), 100 | $attributeClass, 101 | )); 102 | } 103 | 104 | return $attributes[0]; 105 | } 106 | 107 | private function determineHeaders() 108 | { 109 | $headers = $this->definition->attributes()->ofType(Header::class); 110 | foreach ($headers as $header) { 111 | $this->staticHeaders[] = $header; 112 | } 113 | } 114 | 115 | public function __clone(): void 116 | { 117 | $this->request = null; 118 | $this->response = null; 119 | } 120 | 121 | public function withArguments(array $arguments): self 122 | { 123 | $clone = clone $this; 124 | $clone->templateArguments = []; 125 | $clone->dynamicHeaders = []; 126 | $clone->requestBody = null; 127 | 128 | $formFields = []; 129 | 130 | $parameters = $clone->definition->parameters(); 131 | foreach ($arguments as $key => $argument) { 132 | if (\is_numeric($key)) { 133 | $param = $parameters->at($key); 134 | } else { 135 | $param = $parameters->get($key); 136 | } 137 | 138 | if (self::attribute($param, Body::class)) { 139 | if (!$argument instanceof RequestBody) { 140 | throw new ServiceException(\sprintf( 141 | 'Invalid argument type for #[Body] parameter (%s), must be %s', 142 | $param->signature(), 143 | RequestBody::class, 144 | )); 145 | } 146 | 147 | if ($clone->requestBody !== null) { 148 | throw new ServiceException( 149 | 'Duplicate request body, do you have multiple parameters with the #[Body] attribute?' 150 | ); 151 | } 152 | 153 | $clone->requestBody = $argument; 154 | } else if ($attribute = self::attribute($param, HeaderValue::class)) { 155 | if (!\is_string($argument)) { 156 | throw new ServiceException(\sprintf( 157 | 'Invalid argument type for #[HeaderValue] parameter (%s), must be string', 158 | $param->signature(), 159 | )); 160 | } 161 | 162 | $clone->dynamicHeaders[] = new Header($attribute->getName(), $argument); 163 | } else if ($attribute = self::attribute($param, FieldValue::class)) { 164 | if (!\is_string($argument)) { 165 | throw new ServiceException(\sprintf( 166 | 'Invalid argument type for #[Field] parameter (%s), must be string', 167 | $param->signature(), 168 | )); 169 | } 170 | 171 | // Temporarily store in a variable, 172 | // so we can set these fields on a custom FormBody instance set in another parameter. 173 | $formFields[] = [$attribute->getName(), $argument]; 174 | } else { 175 | $clone->templateArguments[$param->name()] = $argument; 176 | } 177 | } 178 | 179 | if ($formFields) { 180 | $clone->requestBody ??= new FormBody(); 181 | if (!$clone->requestBody instanceof FormBody) { 182 | throw new ServiceException( 183 | 'Unable to set form field parameter due to a custom request body' 184 | ); 185 | } 186 | 187 | foreach ($formFields as [$name, $value]) { 188 | $clone->requestBody->addField($name, $value); 189 | } 190 | } 191 | 192 | $variableNames = $clone->uriTemplate->getVariableNames(); 193 | foreach ($variableNames as $variableName) { 194 | if (!\array_key_exists($variableName, $clone->templateArguments)) { 195 | throw new ServiceException(\sprintf( 196 | 'Missing parameter $%s for variable expansion in %s at %s', 197 | $variableName, 198 | $clone->uriTemplate->getTemplate(), 199 | $clone->definition->signature(), 200 | )); 201 | } 202 | } 203 | 204 | foreach ($clone->templateArguments as $name => $value) { 205 | if (!\in_array($name, $variableNames)) { 206 | throw new ServiceException(\sprintf( 207 | 'Parameter $%s isn\'t used in the URI template nor has any special attribute indicating its use at %s', 208 | $name, 209 | $clone->definition->signature(), 210 | )); 211 | } 212 | } 213 | 214 | return $clone; 215 | } 216 | 217 | public function execute(?Cancellation $cancellation = null): mixed 218 | { 219 | $uri = $this->uriTemplate->expand($this->templateArguments); 220 | $this->request = new Request($uri, $this->requestMethod); 221 | 222 | foreach ($this->staticHeaders as $header) { 223 | $this->request->addHeader($header->getName(), $header->getValue()); 224 | } 225 | 226 | foreach ($this->dynamicHeaders as $header) { 227 | $this->request->addHeader($header->getName(), $header->getValue()); 228 | } 229 | 230 | $this->response = $this->httpClient->request($this->request, $cancellation); 231 | $responseBody = $this->response->getBody()->buffer(); 232 | 233 | return $this->mapper->map($this->bodyType->toString(), $this->serializer->unserialize($responseBody)); 234 | } 235 | 236 | public function getRequest(): Request 237 | { 238 | return $this->request ?? throw new ServiceException('Request is not available yet, ensure execute() has been called.'); 239 | } 240 | 241 | public function getResponse(): Response 242 | { 243 | return $this->response ?? throw new ServiceException('Response is not available yet, ensure execute() has been called.'); 244 | } 245 | } --------------------------------------------------------------------------------