├── .github └── workflows │ └── close_pr.yml ├── ApiPlatformDeferredProvider.php ├── ApiPlatformMiddleware.php ├── ApiPlatformProvider.php ├── ApiResource ├── Error.php └── ValidationError.php ├── CONTRIBUTING.md ├── Console ├── InstallCommand.php └── Maker │ ├── AbstractMakeStateCommand.php │ ├── MakeStateProcessorCommand.php │ ├── MakeStateProviderCommand.php │ ├── Resources │ └── skeleton │ │ ├── StateProcessor.php.tpl │ │ └── StateProvider.php.tpl │ └── Utils │ ├── AppServiceProviderTagger.php │ ├── StateTemplateGenerator.php │ ├── StateTypeEnum.php │ └── SuccessMessageTrait.php ├── Controller ├── ApiPlatformController.php ├── DocumentationController.php └── EntrypointController.php ├── Eloquent ├── Extension │ ├── FilterQueryExtension.php │ └── QueryExtensionInterface.php ├── Filter │ ├── BooleanFilter.php │ ├── DateFilter.php │ ├── EndSearchFilter.php │ ├── EqualsFilter.php │ ├── FilterInterface.php │ ├── JsonApi │ │ ├── SortFilter.php │ │ └── SortFilterParameterProvider.php │ ├── OrFilter.php │ ├── OrderFilter.php │ ├── PartialSearchFilter.php │ ├── QueryPropertyTrait.php │ ├── RangeFilter.php │ └── StartSearchFilter.php ├── Metadata │ ├── Factory │ │ ├── Property │ │ │ ├── EloquentAttributePropertyMetadataFactory.php │ │ │ ├── EloquentPropertyMetadataFactory.php │ │ │ └── EloquentPropertyNameCollectionMetadataFactory.php │ │ └── Resource │ │ │ └── EloquentResourceCollectionMetadataFactory.php │ ├── IdentifiersExtractor.php │ ├── ModelMetadata.php │ └── ResourceClassResolver.php ├── Paginator.php ├── PartialPaginator.php ├── PropertyAccess │ └── PropertyAccessor.php ├── Serializer │ └── SerializerContextBuilder.php └── State │ ├── CollectionProvider.php │ ├── ItemProvider.php │ ├── LinksHandler.php │ ├── LinksHandlerInterface.php │ ├── LinksHandlerLocatorTrait.php │ ├── Options.php │ ├── PersistProcessor.php │ └── RemoveProcessor.php ├── Exception └── ErrorHandler.php ├── GraphQl └── Controller │ ├── EntrypointController.php │ └── GraphiQlController.php ├── JsonApi └── State │ └── JsonApiProvider.php ├── LICENSE ├── Metadata ├── CachePropertyMetadataFactory.php ├── CachePropertyNameCollectionMetadataFactory.php ├── CacheResourceCollectionMetadataFactory.php └── ParameterValidationResourceMetadataCollectionFactory.php ├── README.md ├── Routing ├── IriConverter.php ├── Router.php └── SkolemIriConverter.php ├── Security └── ResourceAccessChecker.php ├── ServiceLocator.php ├── State ├── AccessCheckerProvider.php ├── ParameterValidatorProvider.php ├── SwaggerUiProcessor.php ├── SwaggerUiProvider.php ├── ValidateProvider.php └── ValidationErrorTrait.php ├── Test ├── ApiTestAssertionsTrait.php └── Constraint │ ├── ArraySubset.php │ └── ArraySubsetTrait.php ├── composer.json ├── config └── api-platform.php ├── public ├── 400.css ├── 700.css ├── car.svg ├── es6-promise │ └── es6-promise.auto.min.js ├── fetch │ └── fetch.js ├── fonts │ └── open-sans │ │ ├── 400.css │ │ ├── 700.css │ │ └── files │ │ ├── open-sans-cyrillic-400-normal.woff │ │ ├── open-sans-cyrillic-400-normal.woff2 │ │ ├── open-sans-cyrillic-700-normal.woff │ │ ├── open-sans-cyrillic-700-normal.woff2 │ │ ├── open-sans-cyrillic-ext-400-normal.woff │ │ ├── open-sans-cyrillic-ext-400-normal.woff2 │ │ ├── open-sans-cyrillic-ext-700-normal.woff │ │ ├── open-sans-cyrillic-ext-700-normal.woff2 │ │ ├── open-sans-greek-400-normal.woff │ │ ├── open-sans-greek-400-normal.woff2 │ │ ├── open-sans-greek-700-normal.woff │ │ ├── open-sans-greek-700-normal.woff2 │ │ ├── open-sans-greek-ext-400-normal.woff │ │ ├── open-sans-greek-ext-400-normal.woff2 │ │ ├── open-sans-greek-ext-700-normal.woff │ │ ├── open-sans-greek-ext-700-normal.woff2 │ │ ├── open-sans-hebrew-400-normal.woff │ │ ├── open-sans-hebrew-400-normal.woff2 │ │ ├── open-sans-hebrew-700-normal.woff │ │ ├── open-sans-hebrew-700-normal.woff2 │ │ ├── open-sans-latin-400-normal.woff │ │ ├── open-sans-latin-400-normal.woff2 │ │ ├── open-sans-latin-700-normal.woff │ │ ├── open-sans-latin-700-normal.woff2 │ │ ├── open-sans-latin-ext-400-normal.woff │ │ ├── open-sans-latin-ext-400-normal.woff2 │ │ ├── open-sans-latin-ext-700-normal.woff │ │ ├── open-sans-latin-ext-700-normal.woff2 │ │ ├── open-sans-math-400-normal.woff │ │ ├── open-sans-math-400-normal.woff2 │ │ ├── open-sans-math-700-normal.woff │ │ ├── open-sans-math-700-normal.woff2 │ │ ├── open-sans-symbols-400-normal.woff │ │ ├── open-sans-symbols-400-normal.woff2 │ │ ├── open-sans-symbols-700-normal.woff │ │ ├── open-sans-symbols-700-normal.woff2 │ │ ├── open-sans-vietnamese-400-normal.woff │ │ ├── open-sans-vietnamese-400-normal.woff2 │ │ ├── open-sans-vietnamese-700-normal.woff │ │ └── open-sans-vietnamese-700-normal.woff2 ├── graphiql-style.css ├── graphiql │ ├── graphiql.css │ └── graphiql.min.js ├── graphql-playground-style.css ├── graphql-playground │ ├── index.css │ └── middleware.js ├── init-common-ui.js ├── init-graphiql.js ├── init-graphql-playground.js ├── init-redoc-ui.js ├── init-swagger-ui.js ├── logo-header.svg ├── react │ ├── react-dom.production.min.js │ └── react.production.min.js ├── redoc │ └── redoc.standalone.js ├── style.css ├── swagger-ui-bundle.js ├── swagger-ui-standalone-preset.js ├── swagger-ui.css ├── swagger-ui │ ├── oauth2-redirect.html │ ├── swagger-ui-bundle.js │ ├── swagger-ui-standalone-preset.js │ ├── swagger-ui.css │ └── swagger-ui.css.map ├── web.png └── webby.png ├── resources └── views │ ├── graphiql.blade.php │ └── swagger-ui.blade.php ├── routes └── api.php └── testbench.yaml /.github/workflows/close_pr.yml: -------------------------------------------------------------------------------- 1 | name: Close Pull Request 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened] 6 | 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: superbrothers/close-pull-request@v3 12 | with: 13 | comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" 14 | -------------------------------------------------------------------------------- /ApiPlatformMiddleware.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel; 15 | 16 | use ApiPlatform\Metadata\HttpOperation; 17 | use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactory; 18 | use Illuminate\Http\Request; 19 | use Symfony\Component\HttpFoundation\Response; 20 | 21 | class ApiPlatformMiddleware 22 | { 23 | public function __construct( 24 | protected OperationMetadataFactory $operationMetadataFactory, 25 | ) { 26 | } 27 | 28 | /** 29 | * @param \Closure(Request): (Response) $next 30 | */ 31 | public function handle(Request $request, \Closure $next, ?string $operationName = null): Response 32 | { 33 | $operation = null; 34 | if ($operationName) { 35 | $request->attributes->set('_api_operation', $operation = $this->operationMetadataFactory->create($operationName)); 36 | } 37 | 38 | if (!($format = $request->route('_format')) && $operation instanceof HttpOperation && str_ends_with($operation->getUriTemplate(), '{._format}')) { 39 | $matches = []; 40 | if (preg_match('/\.[a-zA-Z]+$/', $request->getPathInfo(), $matches)) { 41 | $format = $matches[0]; 42 | } 43 | } 44 | 45 | $request->attributes->set('_format', $format ? substr($format, 1, \strlen($format) - 1) : ''); 46 | 47 | return $next($request); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Laravel Integration of API Platform 2 | 3 | Pull requests should be made at https://github.com/api-plaform/core 4 | 5 | ## Tests 6 | 7 | cd src/Laravel 8 | composer global require soyuka/pmu 9 | composer global link ../../ 10 | vendor/bin/testbench workbench:build 11 | vendor/bin/testbench api-platform:install 12 | vendor/bin/testbench package:test 13 | # or 14 | vendor/bin/phpunit 15 | 16 | A command is available to remove the database: 17 | 18 | vendor/bin/testbench workbench:drop-sqlite-db 19 | 20 | ## Starting the Test App 21 | 22 | The test server is also available through: 23 | 24 | vendor/bin/testbench serve 25 | -------------------------------------------------------------------------------- /Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console; 15 | 16 | use Illuminate\Console\Command; 17 | use Symfony\Component\Console\Attribute\AsCommand; 18 | 19 | #[AsCommand(name: 'api-platform:install')] 20 | class InstallCommand extends Command 21 | { 22 | /** 23 | * @var string 24 | */ 25 | protected $signature = 'api-platform:install'; 26 | 27 | /** 28 | * @var string 29 | */ 30 | protected $description = 'Install all of the API Platform resources'; 31 | 32 | /** 33 | * Execute the console command. 34 | */ 35 | public function handle(): void 36 | { 37 | $this->comment('Publishing API Platform Assets...'); 38 | $this->callSilent('vendor:publish', ['--tag' => 'api-platform-assets']); 39 | 40 | $this->comment('Publishing API Platform Configuration...'); 41 | $this->callSilent('vendor:publish', ['--tag' => 'api-platform-config']); 42 | 43 | $this->info('API Platform installed successfully.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Console/Maker/AbstractMakeStateCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker; 15 | 16 | use ApiPlatform\Laravel\Console\Maker\Utils\AppServiceProviderTagger; 17 | use ApiPlatform\Laravel\Console\Maker\Utils\StateTemplateGenerator; 18 | use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; 19 | use ApiPlatform\Laravel\Console\Maker\Utils\SuccessMessageTrait; 20 | use Illuminate\Console\Command; 21 | use Illuminate\Contracts\Filesystem\FileNotFoundException; 22 | use Illuminate\Filesystem\Filesystem; 23 | 24 | abstract class AbstractMakeStateCommand extends Command 25 | { 26 | use SuccessMessageTrait; 27 | 28 | public function __construct( 29 | private readonly Filesystem $filesystem, 30 | private readonly StateTemplateGenerator $stateTemplateGenerator, 31 | private readonly AppServiceProviderTagger $appServiceProviderTagger, 32 | ) { 33 | parent::__construct(); 34 | } 35 | 36 | /** 37 | * @throws FileNotFoundException 38 | */ 39 | public function handle(): int 40 | { 41 | $stateName = $this->askForStateName(); 42 | 43 | $directoryPath = base_path('app/State/'); 44 | $this->filesystem->ensureDirectoryExists($directoryPath); 45 | 46 | $filePath = $this->stateTemplateGenerator->getFilePath($directoryPath, $stateName); 47 | if ($this->filesystem->exists($filePath)) { 48 | $this->error(\sprintf('[ERROR] The file "%s" can\'t be generated because it already exists.', $filePath)); 49 | 50 | return self::FAILURE; 51 | } 52 | 53 | $this->stateTemplateGenerator->generate($filePath, $stateName, $this->getStateType()); 54 | if (!$this->filesystem->exists($filePath)) { 55 | $this->error(\sprintf('[ERROR] The file "%s" could not be created.', $filePath)); 56 | 57 | return self::FAILURE; 58 | } 59 | 60 | $this->appServiceProviderTagger->addTagToServiceProvider($stateName, $this->getStateType()); 61 | 62 | $this->writeSuccessMessage($filePath, $this->getStateType()); 63 | 64 | return self::SUCCESS; 65 | } 66 | 67 | protected function askForStateName(): string 68 | { 69 | do { 70 | $stateType = $this->getStateType()->name; 71 | $stateName = $this->ask(\sprintf('Choose a class name for your state %s (e.g. AwesomeState%s)', strtolower($stateType), ucfirst($stateType))); 72 | if (empty($stateName)) { 73 | $this->error('[ERROR] This value cannot be blank.'); 74 | } 75 | } while (empty($stateName)); 76 | 77 | return $stateName; 78 | } 79 | 80 | abstract protected function getStateType(): StateTypeEnum; 81 | } 82 | -------------------------------------------------------------------------------- /Console/Maker/MakeStateProcessorCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker; 15 | 16 | use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; 17 | 18 | final class MakeStateProcessorCommand extends AbstractMakeStateCommand 19 | { 20 | protected $signature = 'make:state-processor'; 21 | protected $description = 'Creates an API Platform state processor'; 22 | 23 | protected function getStateType(): StateTypeEnum 24 | { 25 | return StateTypeEnum::Processor; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Console/Maker/MakeStateProviderCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker; 15 | 16 | use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; 17 | 18 | final class MakeStateProviderCommand extends AbstractMakeStateCommand 19 | { 20 | protected $signature = 'make:state-provider'; 21 | protected $description = 'Creates an API Platform state provider'; 22 | 23 | protected function getStateType(): StateTypeEnum 24 | { 25 | return StateTypeEnum::Provider; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Console/Maker/Resources/skeleton/StateProcessor.php.tpl: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker\Utils; 15 | 16 | use Illuminate\Contracts\Filesystem\FileNotFoundException; 17 | use Illuminate\Filesystem\Filesystem; 18 | 19 | final readonly class AppServiceProviderTagger 20 | { 21 | /** @var string */ 22 | private const APP_SERVICE_PROVIDER_PATH = 'Providers/AppServiceProvider.php'; 23 | 24 | /** @var string */ 25 | private const ITEM_PROVIDER_USE_STATEMENT = 'use ApiPlatform\State\ProviderInterface;'; 26 | 27 | /** @var string */ 28 | private const ITEM_PROCESSOR_USE_STATEMENT = 'use ApiPlatform\State\ProcessorInterface;'; 29 | 30 | public function __construct(private Filesystem $filesystem) 31 | { 32 | } 33 | 34 | /** 35 | * @throws FileNotFoundException 36 | */ 37 | public function addTagToServiceProvider(string $providerName, StateTypeEnum $stateTypeEnum): void 38 | { 39 | $appServiceProviderPath = app_path(self::APP_SERVICE_PROVIDER_PATH); 40 | if (!$this->filesystem->exists($appServiceProviderPath)) { 41 | throw new \RuntimeException('The AppServiceProvider is missing!'); 42 | } 43 | 44 | $serviceProviderContent = $this->filesystem->get($appServiceProviderPath); 45 | 46 | $this->addUseStatement($serviceProviderContent, $this->getStateTypeStatement($stateTypeEnum)); 47 | $this->addUseStatement($serviceProviderContent, \sprintf('use App\\State\\%s;', $providerName)); 48 | $this->addTag($serviceProviderContent, $providerName, $appServiceProviderPath, $stateTypeEnum); 49 | } 50 | 51 | private function addUseStatement(string &$content, string $useStatement): void 52 | { 53 | if (!str_contains($content, $useStatement)) { 54 | $content = preg_replace( 55 | '/^(namespace\s[^;]+;\s*)(\n)/m', 56 | "$1\n$useStatement$2", 57 | $content, 58 | 1 59 | ); 60 | } 61 | } 62 | 63 | private function addTag(string &$content, string $stateName, string $serviceProviderPath, StateTypeEnum $stateTypeEnum): void 64 | { 65 | $tagStatement = \sprintf("\n\n\t\t\$this->app->tag(%s::class, %sInterface::class);", $stateName, $stateTypeEnum->name); 66 | 67 | if (!str_contains($content, $tagStatement)) { 68 | $content = preg_replace( 69 | '/(public function register\(\)[^{]*{)(.*?)(\s*}\s*})/s', 70 | "$1$2$tagStatement$3", 71 | $content 72 | ); 73 | 74 | $this->filesystem->put($serviceProviderPath, $content); 75 | } 76 | } 77 | 78 | private function getStateTypeStatement(StateTypeEnum $stateTypeEnum): string 79 | { 80 | return match ($stateTypeEnum) { 81 | StateTypeEnum::Provider => self::ITEM_PROVIDER_USE_STATEMENT, 82 | StateTypeEnum::Processor => self::ITEM_PROCESSOR_USE_STATEMENT, 83 | }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Console/Maker/Utils/StateTemplateGenerator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker\Utils; 15 | 16 | use Illuminate\Contracts\Filesystem\FileNotFoundException; 17 | use Illuminate\Filesystem\Filesystem; 18 | 19 | final readonly class StateTemplateGenerator 20 | { 21 | public function __construct(private Filesystem $filesystem) 22 | { 23 | } 24 | 25 | public function getFilePath(string $directoryPath, string $stateFileName): string 26 | { 27 | return $directoryPath.$stateFileName.'.php'; 28 | } 29 | 30 | /** 31 | * @throws FileNotFoundException 32 | */ 33 | public function generate(string $pathLink, string $stateClassName, StateTypeEnum $stateTypeEnum): void 34 | { 35 | $namespace = 'App\\State'; 36 | $template = $this->loadTemplate($stateTypeEnum); 37 | 38 | $content = strtr($template, [ 39 | '{{ namespace }}' => $namespace, 40 | '{{ class_name }}' => $stateClassName, 41 | ]); 42 | 43 | $this->filesystem->put($pathLink, $content); 44 | } 45 | 46 | /** 47 | * @throws FileNotFoundException 48 | */ 49 | private function loadTemplate(StateTypeEnum $stateTypeEnum): string 50 | { 51 | $templateFile = match ($stateTypeEnum) { 52 | StateTypeEnum::Provider => 'StateProvider.php.tpl', 53 | StateTypeEnum::Processor => 'StateProcessor.php.tpl', 54 | }; 55 | 56 | $templatePath = \dirname(__DIR__).'/Resources/skeleton/'.$templateFile; 57 | 58 | return $this->filesystem->get($templatePath); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Console/Maker/Utils/StateTypeEnum.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker\Utils; 15 | 16 | enum StateTypeEnum 17 | { 18 | case Provider; 19 | case Processor; 20 | } 21 | -------------------------------------------------------------------------------- /Console/Maker/Utils/SuccessMessageTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Console\Maker\Utils; 15 | 16 | trait SuccessMessageTrait 17 | { 18 | private function writeSuccessMessage(string $filePath, StateTypeEnum $stateTypeEnum): void 19 | { 20 | $stateText = strtolower($stateTypeEnum->name); 21 | 22 | $this->newLine(); 23 | $this->line(' '); 24 | $this->line(' Success! '); 25 | $this->line(' '); 26 | $this->newLine(); 27 | $this->line('created: '.$filePath.''); 28 | $this->newLine(); 29 | $this->line("Next: Open your new state $stateText class and start customizing it."); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Controller/ApiPlatformController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Controller; 15 | 16 | use ApiPlatform\Metadata\HttpOperation; 17 | use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; 18 | use ApiPlatform\State\ProcessorInterface; 19 | use ApiPlatform\State\ProviderInterface; 20 | use Illuminate\Http\Request; 21 | use Illuminate\Routing\Controller; 22 | use Symfony\Component\HttpFoundation\Response; 23 | 24 | class ApiPlatformController extends Controller 25 | { 26 | /** 27 | * @param ProviderInterface $provider 28 | * @param ProcessorInterface|object|null, Response> $processor 29 | */ 30 | public function __construct( 31 | protected OperationMetadataFactoryInterface $operationMetadataFactory, 32 | protected ProviderInterface $provider, 33 | protected ProcessorInterface $processor, 34 | ) { 35 | } 36 | 37 | /** 38 | * Display a listing of the resource. 39 | */ 40 | public function __invoke(Request $request): Response 41 | { 42 | $operation = $request->attributes->get('_api_operation'); 43 | if (!$operation) { 44 | throw new \RuntimeException('Operation not found.'); 45 | } 46 | 47 | if (!$operation instanceof HttpOperation) { 48 | throw new \LogicException('Operation is not an HttpOperation.'); 49 | } 50 | 51 | $uriVariables = $this->getUriVariables($request, $operation); 52 | $request->attributes->set('_api_uri_variables', $uriVariables); 53 | // at some point we could introduce that back 54 | // if ($this->uriVariablesConverter) { 55 | // $context = ['operation' => $operation, 'uri_variables_map' => $uriVariablesMap]; 56 | // $identifiers = $this->uriVariablesConverter->convert($identifiers, $operation->getClass() ?? $resourceClass, $context); 57 | // } 58 | 59 | $context = [ 60 | 'request' => $request, 61 | 'uri_variables' => $uriVariables, 62 | 'resource_class' => $operation->getClass(), 63 | ]; 64 | 65 | if (null === $operation->canValidate()) { 66 | $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); 67 | } 68 | 69 | if (null === $operation->canRead()) { 70 | $operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe()); 71 | } 72 | 73 | if (null === $operation->canDeserialize()) { 74 | $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); 75 | } 76 | 77 | $body = $this->provider->provide($operation, $uriVariables, $context); 78 | 79 | // The provider can change the Operation, extract it again from the Request attributes 80 | if ($request->attributes->get('_api_operation') !== $operation) { 81 | $operation = $request->attributes->get('_api_operation'); 82 | $uriVariables = $this->getUriVariables($request, $operation); 83 | } 84 | 85 | $context['previous_data'] = $request->attributes->get('previous_data'); 86 | $context['data'] = $request->attributes->get('data'); 87 | 88 | if (null === $operation->canWrite()) { 89 | $operation = $operation->withWrite(!$request->isMethodSafe()); 90 | } 91 | 92 | if (null === $operation->canSerialize()) { 93 | $operation = $operation->withSerialize(true); 94 | } 95 | 96 | return $this->processor->process($body, $operation, $uriVariables, $context); 97 | } 98 | 99 | /** 100 | * @return array 101 | */ 102 | private function getUriVariables(Request $request, HttpOperation $operation): array 103 | { 104 | $uriVariables = []; 105 | foreach ($operation->getUriVariables() ?? [] as $parameterName => $_) { 106 | $parameter = $request->route($parameterName); 107 | if (\is_string($parameter) && ($format = $request->attributes->get('_format')) && str_contains($parameter, $format)) { 108 | $parameter = substr($parameter, 0, \strlen($parameter) - (\strlen($format) + 1)); 109 | } 110 | 111 | $uriVariables[(string) $parameterName] = $parameter; 112 | } 113 | 114 | return $uriVariables; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Controller/DocumentationController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Controller; 15 | 16 | use ApiPlatform\Documentation\Documentation; 17 | use ApiPlatform\Metadata\Get; 18 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 19 | use ApiPlatform\Metadata\Util\ContentNegotiationTrait; 20 | use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; 21 | use ApiPlatform\OpenApi\OpenApi; 22 | use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer; 23 | use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; 24 | use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; 25 | use ApiPlatform\State\ProcessorInterface; 26 | use ApiPlatform\State\ProviderInterface; 27 | use Negotiation\Negotiator; 28 | use Symfony\Component\HttpFoundation\Request; 29 | use Symfony\Component\HttpFoundation\Response; 30 | 31 | /** 32 | * Generates the API documentation. 33 | * 34 | * @author Amrouche Hamza 35 | */ 36 | final class DocumentationController 37 | { 38 | use ContentNegotiationTrait; 39 | 40 | /** 41 | * @param array $documentationFormats 42 | * @param ProviderInterface $provider 43 | * @param ProcessorInterface $processor 44 | */ 45 | public function __construct( 46 | private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, 47 | private readonly string $title = '', 48 | private readonly string $description = '', 49 | private readonly string $version = '', 50 | private readonly ?OpenApiFactoryInterface $openApiFactory = null, 51 | private readonly ?ProviderInterface $provider = null, 52 | private readonly ?ProcessorInterface $processor = null, 53 | ?Negotiator $negotiator = null, 54 | private readonly array $documentationFormats = [OpenApiNormalizer::JSON_FORMAT => ['application/vnd.openapi+json'], OpenApiNormalizer::FORMAT => ['application/json']], 55 | private readonly bool $swaggerUiEnabled = true, 56 | ) { 57 | $this->negotiator = $negotiator ?? new Negotiator(); 58 | } 59 | 60 | public function __invoke(Request $request): Response 61 | { 62 | $context = [ 63 | 'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), 64 | 'base_url' => $request->getBaseUrl(), 65 | 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), 66 | ]; 67 | $request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context); 68 | // We want to find the format early on, this code is also executed later on by the ContentNegotiationProvider. 69 | $this->addRequestFormats($request, $this->documentationFormats); 70 | $format = $this->getRequestFormat($request, $this->documentationFormats); 71 | 72 | if ('html' === $format || OpenApiNormalizer::FORMAT === $format || OpenApiNormalizer::JSON_FORMAT === $format || OpenApiNormalizer::YAML_FORMAT === $format) { 73 | return $this->getOpenApiDocumentation($context, $format, $request); 74 | } 75 | 76 | return $this->getHydraDocumentation($context, $request); 77 | } 78 | 79 | /** 80 | * @param array $context 81 | */ 82 | private function getOpenApiDocumentation(array $context, string $format, Request $request): Response 83 | { 84 | $context['request'] = $request; 85 | $operation = new Get( 86 | class: OpenApi::class, 87 | read: true, 88 | serialize: true, 89 | provider: fn () => $this->openApiFactory->__invoke($context), 90 | normalizationContext: [ 91 | ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null, 92 | LegacyOpenApiNormalizer::SPEC_VERSION => $context['spec_version'] ?? null, 93 | ], 94 | outputFormats: $this->documentationFormats 95 | ); 96 | 97 | if ('html' === $format && $this->swaggerUiEnabled) { 98 | $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); 99 | } 100 | 101 | return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context); 102 | } 103 | 104 | /** 105 | * TODO: the logic behind the Hydra Documentation is done in a ApiPlatform\Hydra\Serializer\DocumentationNormalizer. 106 | * We should transform this to a provider, it'd improve performances also by a bit. 107 | * 108 | * @param array $context 109 | */ 110 | private function getHydraDocumentation(array $context, Request $request): Response 111 | { 112 | $context['request'] = $request; 113 | $operation = new Get( 114 | class: Documentation::class, 115 | read: true, 116 | serialize: true, 117 | provider: fn () => new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version) 118 | ); 119 | 120 | return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Controller/EntrypointController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Controller; 15 | 16 | use ApiPlatform\Documentation\Entrypoint; 17 | use ApiPlatform\Metadata\Get; 18 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 19 | use ApiPlatform\Metadata\Resource\ResourceNameCollection; 20 | use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; 21 | use ApiPlatform\State\ProcessorInterface; 22 | use ApiPlatform\State\ProviderInterface; 23 | use Symfony\Component\HttpFoundation\Request; 24 | use Symfony\Component\HttpFoundation\Response; 25 | 26 | /** 27 | * Generates the API entrypoint. 28 | * 29 | * @author Kévin Dunglas 30 | */ 31 | final class EntrypointController 32 | { 33 | private static ResourceNameCollection $resourceNameCollection; 34 | 35 | /** 36 | * @param array $documentationFormats 37 | * @param ProviderInterface $provider 38 | * @param ProcessorInterface $processor 39 | */ 40 | public function __construct( 41 | private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, 42 | private readonly ProviderInterface $provider, 43 | private readonly ProcessorInterface $processor, 44 | private readonly array $documentationFormats = [], 45 | ) { 46 | } 47 | 48 | public function __invoke(Request $request): Response 49 | { 50 | self::$resourceNameCollection = $this->resourceNameCollectionFactory->create(); 51 | $context = [ 52 | 'request' => $request, 53 | 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), 54 | ]; 55 | $request->attributes->set('_api_platform_disable_listeners', true); 56 | $operation = new Get( 57 | outputFormats: $this->documentationFormats, 58 | read: true, 59 | serialize: true, 60 | class: Entrypoint::class, 61 | provider: [self::class, 'provide'] 62 | ); 63 | $request->attributes->set('_api_operation', $operation); 64 | $body = $this->provider->provide($operation, [], $context); 65 | $operation = $request->attributes->get('_api_operation'); 66 | 67 | return $this->processor->process($body, $operation, [], $context); 68 | } 69 | 70 | public static function provide(): Entrypoint 71 | { 72 | return new Entrypoint(self::$resourceNameCollection); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Eloquent/Extension/FilterQueryExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Extension; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface; 17 | use ApiPlatform\Metadata\CollectionOperationInterface; 18 | use ApiPlatform\Metadata\Operation; 19 | use ApiPlatform\State\ParameterNotFound; 20 | use Illuminate\Database\Eloquent\Builder; 21 | use Illuminate\Database\Eloquent\Model; 22 | use Psr\Container\ContainerInterface; 23 | 24 | final readonly class FilterQueryExtension implements QueryExtensionInterface 25 | { 26 | public function __construct( 27 | private ContainerInterface $filterLocator, 28 | ) { 29 | } 30 | 31 | /** 32 | * @param Builder $builder 33 | * @param array $uriVariables 34 | * @param array $context 35 | * 36 | * @return Builder 37 | */ 38 | public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder 39 | { 40 | if (!$operation instanceof CollectionOperationInterface) { 41 | return $builder; 42 | } 43 | 44 | $context['uri_variables'] = $uriVariables; 45 | $context['operation'] = $operation; 46 | 47 | foreach ($operation->getParameters() ?? [] as $parameter) { 48 | if (null === ($values = $parameter->getValue()) || $values instanceof ParameterNotFound) { 49 | continue; 50 | } 51 | 52 | if (null === ($filterId = $parameter->getFilter())) { 53 | continue; 54 | } 55 | 56 | // most eloquent filters work with only a single value 57 | if (\is_array($values) && array_is_list($values) && 1 === \count($values)) { 58 | $values = current($values); 59 | } 60 | 61 | $filter = $filterId instanceof FilterInterface ? $filterId : ($this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null); 62 | if ($filter instanceof FilterInterface) { 63 | $builder = $filter->apply($builder, $values, $parameter, $context + ($parameter->getFilterContext() ?? [])); 64 | } 65 | } 66 | 67 | return $builder; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Eloquent/Extension/QueryExtensionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Extension; 15 | 16 | use ApiPlatform\Metadata\Operation; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | interface QueryExtensionInterface 21 | { 22 | /** 23 | * @param Builder $builder 24 | * @param array $uriVariables 25 | * @param array $context 26 | * 27 | * @return Builder 28 | */ 29 | public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder; 30 | } 31 | -------------------------------------------------------------------------------- /Eloquent/Filter/BooleanFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 17 | use ApiPlatform\Metadata\Parameter; 18 | use Illuminate\Database\Eloquent\Builder; 19 | use Illuminate\Database\Eloquent\Model; 20 | 21 | final class BooleanFilter implements FilterInterface, JsonSchemaFilterInterface 22 | { 23 | use QueryPropertyTrait; 24 | 25 | private const BOOLEAN_VALUES = [ 26 | 'true' => true, 27 | 'false' => false, 28 | '1' => true, 29 | '0' => false, 30 | ]; 31 | 32 | /** 33 | * @param Builder $builder 34 | * @param array $context 35 | */ 36 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 37 | { 38 | if (!\is_string($values) || !\array_key_exists($values, self::BOOLEAN_VALUES)) { 39 | return $builder; 40 | } 41 | 42 | return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), $values); 43 | } 44 | 45 | public function getSchema(Parameter $parameter): array 46 | { 47 | return ['type' => 'boolean']; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Eloquent/Filter/DateFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 17 | use ApiPlatform\Metadata\OpenApiParameterFilterInterface; 18 | use ApiPlatform\Metadata\Parameter; 19 | use ApiPlatform\Metadata\QueryParameter; 20 | use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; 21 | use Illuminate\Database\Eloquent\Builder; 22 | use Illuminate\Database\Eloquent\Model; 23 | 24 | final class DateFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface 25 | { 26 | use QueryPropertyTrait; 27 | 28 | private const OPERATOR_VALUE = [ 29 | 'eq' => '=', 30 | 'gt' => '>', 31 | 'lt' => '<', 32 | 'gte' => '>=', 33 | 'lte' => '<=', 34 | ]; 35 | 36 | /** 37 | * @param Builder $builder 38 | * @param array $context 39 | */ 40 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 41 | { 42 | if (!\is_array($values)) { 43 | return $builder; 44 | } 45 | 46 | $values = array_intersect_key($values, self::OPERATOR_VALUE); 47 | 48 | if (!$values) { 49 | return $builder; 50 | } 51 | 52 | if (true === ($parameter->getFilterContext()['include_nulls'] ?? false)) { 53 | foreach ($values as $key => $value) { 54 | $datetime = $this->getDateTime($value); 55 | if (null === $datetime) { 56 | continue; 57 | } 58 | $builder->{$context['whereClause'] ?? 'where'}(function (Builder $query) use ($parameter, $datetime, $key): void { 59 | $queryProperty = $this->getQueryProperty($parameter); 60 | $query->whereDate($queryProperty, self::OPERATOR_VALUE[$key], $datetime) 61 | ->orWhereNull($queryProperty); 62 | }); 63 | } 64 | 65 | return $builder; 66 | } 67 | 68 | foreach ($values as $key => $value) { 69 | $datetime = $this->getDateTime($value); 70 | if (null === $datetime) { 71 | continue; 72 | } 73 | $builder = $builder->{($context['whereClause'] ?? 'where').'Date'}($this->getQueryProperty($parameter), self::OPERATOR_VALUE[$key], $datetime); 74 | } 75 | 76 | return $builder; 77 | } 78 | 79 | /** 80 | * @return array 81 | */ 82 | public function getSchema(Parameter $parameter): array 83 | { 84 | return ['type' => 'date']; 85 | } 86 | 87 | /** 88 | * @return OpenApiParameter[] 89 | */ 90 | public function getOpenApiParameters(Parameter $parameter): array 91 | { 92 | $in = $parameter instanceof QueryParameter ? 'query' : 'header'; 93 | $key = $parameter->getKey(); 94 | 95 | return [ 96 | new OpenApiParameter(name: $key.'[eq]', in: $in), 97 | new OpenApiParameter(name: $key.'[gt]', in: $in), 98 | new OpenApiParameter(name: $key.'[lt]', in: $in), 99 | new OpenApiParameter(name: $key.'[gte]', in: $in), 100 | new OpenApiParameter(name: $key.'[lte]', in: $in), 101 | ]; 102 | } 103 | 104 | private function getDateTime(string $value): ?\DateTimeImmutable 105 | { 106 | try { 107 | return new \DateTimeImmutable($value); 108 | } catch (\DateMalformedStringException|\Exception) { 109 | return null; 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Eloquent/Filter/EndSearchFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | final class EndSearchFilter implements FilterInterface 21 | { 22 | use QueryPropertyTrait; 23 | 24 | /** 25 | * @param Builder $builder 26 | * @param array $context 27 | */ 28 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 29 | { 30 | return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), 'like', '%'.$values); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Eloquent/Filter/EqualsFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | final class EqualsFilter implements FilterInterface 21 | { 22 | use QueryPropertyTrait; 23 | 24 | /** 25 | * @param Builder $builder 26 | * @param array $context 27 | */ 28 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 29 | { 30 | return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), $values); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Eloquent/Filter/FilterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | interface FilterInterface 21 | { 22 | /** 23 | * @param Builder $builder 24 | * @param array $context 25 | * 26 | * @return Builder 27 | */ 28 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder; 29 | } 30 | -------------------------------------------------------------------------------- /Eloquent/Filter/JsonApi/SortFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface; 17 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 18 | use ApiPlatform\Metadata\Parameter; 19 | use ApiPlatform\Metadata\ParameterProviderFilterInterface; 20 | use ApiPlatform\Metadata\PropertiesAwareInterface; 21 | use Illuminate\Database\Eloquent\Builder; 22 | use Illuminate\Database\Eloquent\Model; 23 | 24 | final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, ParameterProviderFilterInterface, PropertiesAwareInterface 25 | { 26 | public const ASC = 'asc'; 27 | public const DESC = 'desc'; 28 | 29 | /** 30 | * @param Builder $builder 31 | * @param array $context 32 | */ 33 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 34 | { 35 | if (!\is_array($values)) { 36 | return $builder; 37 | } 38 | 39 | foreach ($values as $order => $dir) { 40 | if (self::ASC !== $dir && self::DESC !== $dir) { 41 | continue; 42 | } 43 | 44 | $builder->orderBy($order, $dir); 45 | } 46 | 47 | return $builder; 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function getSchema(Parameter $parameter): array 54 | { 55 | return ['type' => 'string']; 56 | } 57 | 58 | public static function getParameterProvider(): string 59 | { 60 | return SortFilterParameterProvider::class; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Eloquent/Filter/JsonApi/SortFilterParameterProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter\JsonApi; 15 | 16 | use ApiPlatform\Metadata\Operation; 17 | use ApiPlatform\Metadata\Parameter; 18 | use ApiPlatform\State\ParameterProviderInterface; 19 | 20 | final readonly class SortFilterParameterProvider implements ParameterProviderInterface 21 | { 22 | public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation 23 | { 24 | if (!($operation = $context['operation'] ?? null)) { 25 | return null; 26 | } 27 | 28 | $parameters = $operation->getParameters(); 29 | $properties = $parameter->getExtraProperties()['_properties'] ?? []; 30 | $value = $parameter->getValue(); 31 | 32 | // most eloquent filters work with only a single value 33 | if (\is_array($value) && array_is_list($value) && 1 === \count($value)) { 34 | $value = current($value); 35 | } 36 | 37 | if (!\is_string($value)) { 38 | return $operation; 39 | } 40 | 41 | $values = explode(',', $value); 42 | $orderBy = []; 43 | foreach ($values as $v) { 44 | $dir = SortFilter::ASC; 45 | if (str_starts_with($v, '-')) { 46 | $dir = SortFilter::DESC; 47 | $v = substr($v, 1); 48 | } 49 | 50 | if (\array_key_exists($v, $properties)) { 51 | $orderBy[$properties[$v]] = $dir; 52 | } 53 | } 54 | 55 | $parameters->add($parameter->getKey(), $parameter->withExtraProperties( 56 | ['_api_values' => $orderBy] + $parameter->getExtraProperties() 57 | )); 58 | 59 | return $operation->withParameters($parameters); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Eloquent/Filter/OrFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 17 | use ApiPlatform\Metadata\OpenApiParameterFilterInterface; 18 | use ApiPlatform\Metadata\Parameter; 19 | use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; 20 | use Illuminate\Database\Eloquent\Builder; 21 | use Illuminate\Database\Eloquent\Model; 22 | 23 | final readonly class OrFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface 24 | { 25 | public function __construct(private FilterInterface $filter) 26 | { 27 | } 28 | 29 | /** 30 | * @param Builder $builder 31 | * @param array $context 32 | */ 33 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 34 | { 35 | return $builder->where(function ($builder) use ($values, $parameter, $context): void { 36 | foreach ($values as $value) { 37 | $this->filter->apply($builder, $value, $parameter, ['whereClause' => 'orWhere'] + $context); 38 | } 39 | }); 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | public function getSchema(Parameter $parameter): array 46 | { 47 | $schema = $this->filter instanceof JsonSchemaFilterInterface ? $this->filter->getSchema($parameter) : ['type' => 'string']; 48 | 49 | return ['type' => 'array', 'items' => $schema]; 50 | } 51 | 52 | public function getOpenApiParameters(Parameter $parameter): OpenApiParameter 53 | { 54 | return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Eloquent/Filter/OrderFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 17 | use ApiPlatform\Metadata\OpenApiParameterFilterInterface; 18 | use ApiPlatform\Metadata\Parameter; 19 | use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; 20 | use Illuminate\Database\Eloquent\Builder; 21 | use Illuminate\Database\Eloquent\Model; 22 | 23 | final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface 24 | { 25 | use QueryPropertyTrait; 26 | 27 | /** 28 | * @param Builder $builder 29 | * @param array $context 30 | */ 31 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 32 | { 33 | if (!\is_string($values)) { 34 | $properties = $parameter->getExtraProperties()['_properties'] ?? []; 35 | 36 | foreach ($values as $key => $value) { 37 | if (!isset($properties[$key])) { 38 | continue; 39 | } 40 | $builder = $builder->orderBy($properties[$key], $value); 41 | } 42 | 43 | return $builder; 44 | } 45 | 46 | return $builder->orderBy($this->getQueryProperty($parameter), $values); 47 | } 48 | 49 | /** 50 | * @return array 51 | */ 52 | public function getSchema(Parameter $parameter): array 53 | { 54 | return ['type' => 'string', 'enum' => ['asc', 'desc']]; 55 | } 56 | 57 | /** 58 | * @return OpenApiParameter[]|null 59 | */ 60 | public function getOpenApiParameters(Parameter $parameter): ?array 61 | { 62 | if (str_contains($parameter->getKey(), ':property')) { 63 | $parameters = []; 64 | $key = str_replace('[:property]', '', $parameter->getKey()); 65 | foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { 66 | $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); 67 | } 68 | 69 | return $parameters; 70 | } 71 | 72 | return null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Eloquent/Filter/PartialSearchFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | final class PartialSearchFilter implements FilterInterface 21 | { 22 | use QueryPropertyTrait; 23 | 24 | /** 25 | * @param Builder $builder 26 | * @param array $context 27 | */ 28 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 29 | { 30 | return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), 'like', '%'.$values.'%'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Eloquent/Filter/QueryPropertyTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | 18 | /** 19 | * @internal 20 | */ 21 | trait QueryPropertyTrait 22 | { 23 | private function getQueryProperty(Parameter $parameter): ?string 24 | { 25 | return $parameter->getExtraProperties()['_query_property'] ?? $parameter->getProperty() ?? null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Eloquent/Filter/RangeFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\JsonSchemaFilterInterface; 17 | use ApiPlatform\Metadata\OpenApiParameterFilterInterface; 18 | use ApiPlatform\Metadata\Parameter; 19 | use ApiPlatform\Metadata\QueryParameter; 20 | use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; 21 | use Illuminate\Database\Eloquent\Builder; 22 | use Illuminate\Database\Eloquent\Model; 23 | 24 | final class RangeFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface 25 | { 26 | use QueryPropertyTrait; 27 | 28 | private const OPERATOR_VALUE = [ 29 | 'lt' => '<', 30 | 'gt' => '>', 31 | 'lte' => '<=', 32 | 'gte' => '>=', 33 | ]; 34 | 35 | /** 36 | * @param Builder $builder 37 | * @param array $context 38 | */ 39 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 40 | { 41 | $queryProperty = $this->getQueryProperty($parameter); 42 | 43 | foreach ($values as $key => $value) { 44 | $builder = $builder->{$context['whereClause'] ?? 'where'}($queryProperty, self::OPERATOR_VALUE[$key], $value); 45 | } 46 | 47 | return $builder; 48 | } 49 | 50 | public function getSchema(Parameter $parameter): array 51 | { 52 | return ['type' => 'number']; 53 | } 54 | 55 | /** 56 | * @return OpenApiParameter[] 57 | */ 58 | public function getOpenApiParameters(Parameter $parameter): array 59 | { 60 | $in = $parameter instanceof QueryParameter ? 'query' : 'header'; 61 | $key = $parameter->getKey(); 62 | 63 | return [ 64 | new OpenApiParameter(name: $key.'[gt]', in: $in), 65 | new OpenApiParameter(name: $key.'[lt]', in: $in), 66 | new OpenApiParameter(name: $key.'[gte]', in: $in), 67 | new OpenApiParameter(name: $key.'[lte]', in: $in), 68 | ]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Eloquent/Filter/StartSearchFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Filter; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | final class StartSearchFilter implements FilterInterface 21 | { 22 | use QueryPropertyTrait; 23 | 24 | /** 25 | * @param Builder $builder 26 | * @param array $context 27 | */ 28 | public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder 29 | { 30 | return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), 'like', $values.'%'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Eloquent/Metadata/Factory/Property/EloquentAttributePropertyMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property; 15 | 16 | use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; 17 | use ApiPlatform\Metadata\ApiProperty; 18 | use ApiPlatform\Metadata\Exception\PropertyNotFoundException; 19 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 20 | use Illuminate\Database\Eloquent\Model; 21 | 22 | /** 23 | * Handles Eloquent methods for relations. 24 | */ 25 | final class EloquentAttributePropertyMetadataFactory implements PropertyMetadataFactoryInterface 26 | { 27 | public function __construct( 28 | private readonly ?PropertyMetadataFactoryInterface $decorated = null, 29 | ) { 30 | } 31 | 32 | public function create(string $resourceClass, string $property, array $options = []): ApiProperty 33 | { 34 | if (!class_exists($resourceClass)) { 35 | return $this->decorated?->create($resourceClass, $property, $options) ?? 36 | $this->throwNotFound($resourceClass, $property); 37 | } 38 | 39 | $refl = new \ReflectionClass($resourceClass); 40 | $model = $refl->newInstanceWithoutConstructor(); 41 | 42 | $propertyMetadata = $this->decorated?->create($resourceClass, $property, $options); 43 | if (!$model instanceof Model) { 44 | return $propertyMetadata ?? $this->throwNotFound($resourceClass, $property); 45 | } 46 | 47 | if ($refl->hasMethod($property) && $attributes = $refl->getMethod($property)->getAttributes(ApiProperty::class)) { 48 | return $this->createMetadata($attributes[0]->newInstance(), $propertyMetadata); 49 | } 50 | 51 | return $propertyMetadata; 52 | } 53 | 54 | /** 55 | * @throws PropertyNotFoundException 56 | */ 57 | private function throwNotFound(string $resourceClass, string $property): never 58 | { 59 | throw new PropertyNotFoundException(\sprintf('Property "%s" of class "%s" not found.', $property, $resourceClass)); 60 | } 61 | 62 | private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMetadata = null): ApiProperty 63 | { 64 | if (null === $propertyMetadata) { 65 | return $this->handleUserDefinedSchema($attribute); 66 | } 67 | 68 | foreach (get_class_methods(ApiProperty::class) as $method) { 69 | if (preg_match('/^(?:get|is)(.*)/', $method, $matches) && null !== $val = $attribute->{$method}()) { 70 | $propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val); 71 | } 72 | } 73 | 74 | return $this->handleUserDefinedSchema($propertyMetadata); 75 | } 76 | 77 | private function handleUserDefinedSchema(ApiProperty $propertyMetadata): ApiProperty 78 | { 79 | // can't know later if the schema has been defined by the user or by API Platform 80 | // store extra key to make this difference 81 | if (null !== $propertyMetadata->getSchema()) { 82 | $extraProperties = $propertyMetadata->getExtraProperties() ?? []; 83 | $propertyMetadata = $propertyMetadata->withExtraProperties([SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED => true] + $extraProperties); 84 | } 85 | 86 | return $propertyMetadata; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; 17 | use ApiPlatform\Metadata\ApiProperty; 18 | use ApiPlatform\Metadata\Exception\PropertyNotFoundException; 19 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 20 | use Illuminate\Database\Eloquent\Model; 21 | use Illuminate\Database\Eloquent\Relations\BelongsToMany; 22 | use Illuminate\Database\Eloquent\Relations\HasMany; 23 | use Illuminate\Database\Eloquent\Relations\HasManyThrough; 24 | use Illuminate\Database\Eloquent\Relations\MorphMany; 25 | use Illuminate\Database\Eloquent\Relations\MorphToMany; 26 | use Illuminate\Support\Collection; 27 | use Symfony\Component\TypeInfo\Type; 28 | use Symfony\Component\TypeInfo\TypeIdentifier; 29 | 30 | /** 31 | * Uses Eloquent metadata to populate the identifier property. 32 | * 33 | * @author Kévin Dunglas 34 | */ 35 | final class EloquentPropertyMetadataFactory implements PropertyMetadataFactoryInterface 36 | { 37 | public function __construct( 38 | private readonly ModelMetadata $modelMetadata, 39 | private readonly ?PropertyMetadataFactoryInterface $decorated = null, 40 | ) { 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | * 46 | * @param class-string $resourceClass 47 | */ 48 | public function create(string $resourceClass, string $property, array $options = []): ApiProperty 49 | { 50 | if (!is_a($resourceClass, Model::class, true)) { 51 | return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty(); 52 | } 53 | 54 | try { 55 | $refl = new \ReflectionClass($resourceClass); 56 | $model = $refl->newInstanceWithoutConstructor(); 57 | } catch (\ReflectionException) { 58 | return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty(); 59 | } 60 | 61 | try { 62 | $propertyMetadata = $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty(); 63 | } catch (PropertyNotFoundException) { 64 | $propertyMetadata = new ApiProperty(); 65 | } 66 | 67 | if ($model->getKeyName() === $property) { 68 | $propertyMetadata = $propertyMetadata->withIdentifier(true)->withWritable($propertyMetadata->isWritable() ?? false); 69 | } 70 | 71 | foreach ($this->modelMetadata->getAttributes($model) as $p) { 72 | if ($p['name'] !== $property) { 73 | continue; 74 | } 75 | 76 | // see https://laravel.com/docs/11.x/eloquent-mutators#attribute-casting 77 | $builtinType = $p['cast'] ?? $p['type']; 78 | $type = match ($builtinType) { 79 | 'integer' => Type::int(), 80 | 'double', 'real' => Type::float(), 81 | 'boolean', 'bool' => Type::bool(), 82 | 'datetime', 'date', 'timestamp' => Type::object(\DateTime::class), 83 | 'immutable_datetime', 'immutable_date' => Type::object(\DateTimeImmutable::class), 84 | 'collection', 'encrypted:collection' => Type::collection(Type::object(Collection::class)), 85 | 'encrypted:array' => Type::builtin(TypeIdentifier::ARRAY), 86 | 'encrypted:object' => Type::object(), 87 | default => \in_array($builtinType, TypeIdentifier::values(), true) ? Type::builtin($builtinType) : Type::string(), 88 | }; 89 | 90 | if ($p['nullable']) { 91 | $type = Type::nullable($type); 92 | } 93 | 94 | $propertyMetadata = $propertyMetadata 95 | ->withNativeType($type); 96 | 97 | // If these are set let the SerializerPropertyMetadataFactory do the work 98 | if (!isset($options['denormalization_groups'])) { 99 | $propertyMetadata = $propertyMetadata 100 | ->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable']); 101 | } 102 | 103 | if (!isset($options['normalization_groups'])) { 104 | $propertyMetadata = $propertyMetadata 105 | ->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']); 106 | } 107 | 108 | return $propertyMetadata; 109 | } 110 | 111 | foreach ($this->modelMetadata->getRelations($model) as $relation) { 112 | if ($relation['name'] !== $property) { 113 | continue; 114 | } 115 | 116 | $collection = match ($relation['type']) { 117 | HasMany::class, 118 | HasManyThrough::class, 119 | BelongsToMany::class, 120 | MorphMany::class, 121 | MorphToMany::class => true, 122 | default => false, 123 | }; 124 | 125 | $type = Type::object($relation['related']); 126 | if ($collection) { 127 | $type = Type::iterable($type); 128 | } 129 | 130 | return $propertyMetadata 131 | ->withNativeType($type) 132 | ->withExtraProperties(['eloquent_relation' => $relation] + $propertyMetadata->getExtraProperties()); 133 | } 134 | 135 | return $propertyMetadata; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; 17 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 18 | use ApiPlatform\Metadata\Property\PropertyNameCollection; 19 | use ApiPlatform\Metadata\ResourceClassResolverInterface; 20 | use Illuminate\Database\Eloquent\Model; 21 | 22 | final class EloquentPropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface 23 | { 24 | public function __construct( 25 | private readonly ModelMetadata $modelMetadata, 26 | private readonly ?PropertyNameCollectionFactoryInterface $decorated, 27 | private readonly ResourceClassResolverInterface $resourceClassResolver, 28 | ) { 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | * 34 | * @param class-string $resourceClass 35 | */ 36 | public function create(string $resourceClass, array $options = []): PropertyNameCollection 37 | { 38 | if (!class_exists($resourceClass) || !is_a($resourceClass, Model::class, true)) { 39 | return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); 40 | } 41 | 42 | try { 43 | $refl = new \ReflectionClass($resourceClass); 44 | if ($refl->isAbstract()) { 45 | return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); 46 | } 47 | 48 | $model = $refl->newInstanceWithoutConstructor(); 49 | } catch (\ReflectionException) { 50 | return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); 51 | } 52 | 53 | /** 54 | * @var array $properties 55 | */ 56 | $properties = []; 57 | 58 | // When it's an Eloquent model we read attributes from database (@see ShowModelCommand) 59 | foreach ($this->modelMetadata->getAttributes($model) as $property) { 60 | if (!($property['primary'] ?? null) && $property['hidden']) { 61 | continue; 62 | } 63 | 64 | $properties[$property['name']] = true; 65 | } 66 | 67 | foreach ($this->modelMetadata->getRelations($model) as $relation) { 68 | if (!$this->resourceClassResolver->isResourceClass($relation['related'])) { 69 | continue; 70 | } 71 | 72 | $properties[$relation['name']] = true; 73 | } 74 | 75 | return new PropertyNameCollection( 76 | array_keys($properties) 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata\Factory\Resource; 15 | 16 | use ApiPlatform\Laravel\Eloquent\State\CollectionProvider; 17 | use ApiPlatform\Laravel\Eloquent\State\ItemProvider; 18 | use ApiPlatform\Laravel\Eloquent\State\PersistProcessor; 19 | use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor; 20 | use ApiPlatform\Metadata\CollectionOperationInterface; 21 | use ApiPlatform\Metadata\Delete; 22 | use ApiPlatform\Metadata\DeleteOperationInterface; 23 | use ApiPlatform\Metadata\Get; 24 | use ApiPlatform\Metadata\GetCollection; 25 | use ApiPlatform\Metadata\GraphQl\DeleteMutation; 26 | use ApiPlatform\Metadata\GraphQl\Mutation; 27 | use ApiPlatform\Metadata\GraphQl\Query; 28 | use ApiPlatform\Metadata\GraphQl\QueryCollection; 29 | use ApiPlatform\Metadata\GraphQl\Subscription; 30 | use ApiPlatform\Metadata\Patch; 31 | use ApiPlatform\Metadata\Post; 32 | use ApiPlatform\Metadata\Put; 33 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 34 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 35 | use Illuminate\Database\Eloquent\Model; 36 | use Illuminate\Support\Facades\Gate; 37 | 38 | final class EloquentResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface 39 | { 40 | private const POLICY_METHODS = [ 41 | Put::class => 'update', 42 | Post::class => 'create', 43 | Get::class => 'view', 44 | GetCollection::class => 'viewAny', 45 | Delete::class => 'delete', 46 | Patch::class => 'update', 47 | 48 | Query::class => 'view', 49 | QueryCollection::class => 'viewAny', 50 | Mutation::class => 'update', 51 | DeleteMutation::class => 'delete', 52 | Subscription::class => 'viewAny', 53 | ]; 54 | 55 | public function __construct( 56 | private readonly ResourceMetadataCollectionFactoryInterface $decorated, 57 | ) { 58 | } 59 | 60 | /** 61 | * @param class-string $resourceClass 62 | */ 63 | public function create(string $resourceClass): ResourceMetadataCollection 64 | { 65 | $resourceMetadataCollection = $this->decorated->create($resourceClass); 66 | 67 | try { 68 | $refl = new \ReflectionClass($resourceClass); 69 | $model = $refl->newInstanceWithoutConstructor(); 70 | } catch (\ReflectionException) { 71 | return $this->decorated->create($resourceClass); 72 | } 73 | 74 | if (!$model instanceof Model) { 75 | return $resourceMetadataCollection; 76 | } 77 | 78 | foreach ($resourceMetadataCollection as $i => $resourceMetadata) { 79 | $operations = $resourceMetadata->getOperations(); 80 | foreach ($operations ?? [] as $operationName => $operation) { 81 | if (!$operation->getProvider()) { 82 | $operation = $operation->withProvider($operation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); 83 | } 84 | 85 | if (!$operation->getPolicy() && ($policy = Gate::getPolicyFor($model))) { 86 | $policyMethod = self::POLICY_METHODS[$operation::class] ?? null; 87 | if ($operation instanceof Put && $operation->getAllowCreate()) { 88 | $policyMethod = self::POLICY_METHODS[Post::class]; 89 | } 90 | 91 | if ($policyMethod && method_exists($policy, $policyMethod)) { 92 | $operation = $operation->withPolicy($policyMethod); 93 | } 94 | } 95 | 96 | if (!$operation->getProcessor()) { 97 | $operation = $operation->withProcessor($operation instanceof DeleteOperationInterface ? RemoveProcessor::class : PersistProcessor::class); 98 | } 99 | 100 | $operations->add($operationName, $operation); 101 | } 102 | 103 | $resourceMetadataCollection[$i] = $resourceMetadata->withOperations($operations); 104 | 105 | $graphQlOperations = $resourceMetadata->getGraphQlOperations(); 106 | foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) { 107 | if (!$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) { 108 | if (($policyMethod = self::POLICY_METHODS[$graphQlOperation::class] ?? null) && method_exists($policy, $policyMethod)) { 109 | $graphQlOperation = $graphQlOperation->withPolicy($policyMethod); 110 | } 111 | } 112 | 113 | if (!$graphQlOperation->getProvider()) { 114 | $graphQlOperation = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); 115 | } 116 | 117 | if (!$graphQlOperation->getProcessor()) { 118 | $graphQlOperation = $graphQlOperation->withProcessor($graphQlOperation instanceof DeleteOperationInterface ? RemoveProcessor::class : PersistProcessor::class); 119 | } 120 | 121 | $graphQlOperations[$operationName] = $graphQlOperation; 122 | } 123 | 124 | if ($graphQlOperations) { 125 | $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); 126 | } 127 | 128 | $resourceMetadataCollection[$i] = $resourceMetadata; 129 | } 130 | 131 | return $resourceMetadataCollection; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Eloquent/Metadata/IdentifiersExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata; 15 | 16 | use ApiPlatform\Metadata\HttpOperation; 17 | use ApiPlatform\Metadata\IdentifiersExtractorInterface; 18 | use ApiPlatform\Metadata\Link; 19 | use ApiPlatform\Metadata\Operation; 20 | use Illuminate\Database\Eloquent\Model; 21 | use Illuminate\Database\Eloquent\Relations\BelongsTo; 22 | 23 | final class IdentifiersExtractor implements IdentifiersExtractorInterface 24 | { 25 | public function __construct( 26 | private readonly IdentifiersExtractorInterface $inner, 27 | ) { 28 | } 29 | 30 | public function getIdentifiersFromItem(object $item, ?Operation $operation = null, array $context = []): array 31 | { 32 | if (!($item instanceof BelongsTo || $item instanceof Model) || !$operation instanceof HttpOperation) { 33 | return $this->inner->getIdentifiersFromItem($item, $operation, $context); 34 | } 35 | 36 | $identifiers = []; 37 | foreach ($operation->getUriVariables() ?? [] as $link) { 38 | $parameterName = $link->getParameterName(); 39 | $identifiers[$parameterName] = $this->getIdentifierValue($item, $link); 40 | } 41 | 42 | return $identifiers; 43 | } 44 | 45 | private function getIdentifierValue(object $item, Link $link): mixed 46 | { 47 | if ($item instanceof ($link->getFromClass())) { 48 | return $this->getEloquentProperty($item, $link->getIdentifiers()[0]); 49 | } 50 | 51 | if ($item instanceof BelongsTo) { 52 | return $this->getEloquentProperty($item->getParent(), $item->getForeignKeyName()); 53 | } 54 | 55 | if ($toProperty = $link->getToProperty()) { 56 | $relation = $this->getEloquentProperty($item, $toProperty); 57 | 58 | if ($relation instanceof BelongsTo) { 59 | return $this->getEloquentProperty($item, $relation->getForeignKeyName()); 60 | } 61 | } 62 | 63 | return $this->getEloquentProperty($item, $link->getIdentifiers()[0]); 64 | } 65 | 66 | private function getEloquentProperty(object $item, string $property): mixed 67 | { 68 | if (method_exists($item, $property)) { 69 | return $item->{$property}(); 70 | } 71 | 72 | $getter = 'get'.ucfirst($property); 73 | if (method_exists($item, $getter)) { 74 | return $item->{$getter}(); 75 | } 76 | 77 | return $item->{$property}; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Eloquent/Metadata/ResourceClassResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Metadata; 15 | 16 | use ApiPlatform\Metadata\ResourceClassResolverInterface; 17 | use Illuminate\Database\Eloquent\Relations\Relation; 18 | 19 | final class ResourceClassResolver implements ResourceClassResolverInterface 20 | { 21 | public function __construct( 22 | private readonly ResourceClassResolverInterface $inner, 23 | ) { 24 | } 25 | 26 | public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string 27 | { 28 | if ($value instanceof Relation) { 29 | return $this->inner->getResourceClass($value->getRelated()); 30 | } 31 | 32 | return $this->inner->getResourceClass($value, $resourceClass, $strict); 33 | } 34 | 35 | public function isResourceClass(string $type): bool 36 | { 37 | return $this->inner->isResourceClass($type); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Eloquent/Paginator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent; 15 | 16 | use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; 17 | use ApiPlatform\State\Pagination\PaginatorInterface; 18 | use Illuminate\Pagination\LengthAwarePaginator; 19 | use IteratorAggregate; 20 | 21 | /** 22 | * @implements IteratorAggregate 23 | * @implements PaginatorInterface 24 | */ 25 | final class Paginator implements PaginatorInterface, HasNextPagePaginatorInterface, \IteratorAggregate 26 | { 27 | /** 28 | * @param LengthAwarePaginator $paginator 29 | */ 30 | public function __construct( 31 | private readonly LengthAwarePaginator $paginator, 32 | ) { 33 | } 34 | 35 | public function count(): int 36 | { 37 | return $this->paginator->count(); // @phpstan-ignore-line 38 | } 39 | 40 | public function getLastPage(): float 41 | { 42 | return $this->paginator->lastPage(); 43 | } 44 | 45 | public function getTotalItems(): float 46 | { 47 | return $this->paginator->total(); 48 | } 49 | 50 | public function getCurrentPage(): float 51 | { 52 | return $this->paginator->currentPage(); 53 | } 54 | 55 | public function getItemsPerPage(): float 56 | { 57 | return $this->paginator->perPage(); 58 | } 59 | 60 | public function getIterator(): \Traversable 61 | { 62 | return $this->paginator->getIterator(); 63 | } 64 | 65 | public function hasNextPage(): bool 66 | { 67 | return $this->paginator->hasMorePages(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Eloquent/PartialPaginator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent; 15 | 16 | use ApiPlatform\State\Pagination\PartialPaginatorInterface; 17 | use Illuminate\Pagination\AbstractPaginator; 18 | use IteratorAggregate; 19 | 20 | /** 21 | * @implements IteratorAggregate 22 | * @implements PartialPaginatorInterface 23 | */ 24 | final class PartialPaginator implements PartialPaginatorInterface, \IteratorAggregate 25 | { 26 | /** 27 | * @param AbstractPaginator $paginator 28 | */ 29 | public function __construct( 30 | private readonly AbstractPaginator $paginator, 31 | ) { 32 | } 33 | 34 | public function count(): int 35 | { 36 | return $this->paginator->count(); // @phpstan-ignore-line 37 | } 38 | 39 | public function getCurrentPage(): float 40 | { 41 | return $this->paginator->currentPage(); 42 | } 43 | 44 | public function getItemsPerPage(): float 45 | { 46 | return $this->paginator->perPage(); 47 | } 48 | 49 | public function getIterator(): \Traversable 50 | { 51 | return $this->paginator->getIterator(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Eloquent/PropertyAccess/PropertyAccessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\PropertyAccess; 15 | 16 | use Illuminate\Database\Eloquent\Model; 17 | use Symfony\Component\PropertyAccess\PropertyAccess; 18 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 19 | use Symfony\Component\PropertyAccess\PropertyPathInterface; 20 | 21 | /** 22 | * @internal 23 | */ 24 | final class PropertyAccessor implements PropertyAccessorInterface 25 | { 26 | private readonly PropertyAccessorInterface $inner; 27 | 28 | public function __construct( 29 | ?PropertyAccessorInterface $inner = null, 30 | ) { 31 | $this->inner = $inner ?? PropertyAccess::createPropertyAccessor(); 32 | } 33 | 34 | /** 35 | * @param array|object $objectOrArray 36 | */ 37 | public function setValue(object|array &$objectOrArray, string|PropertyPathInterface $propertyPath, mixed $value): void 38 | { 39 | if ($objectOrArray instanceof Model) { 40 | $objectOrArray->{$propertyPath} = $value; 41 | 42 | return; 43 | } 44 | 45 | $this->inner->setValue($objectOrArray, $propertyPath, $value); 46 | } 47 | 48 | /** 49 | * @param array|object $objectOrArray 50 | */ 51 | public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed 52 | { 53 | if ($objectOrArray instanceof Model) { 54 | return $objectOrArray->{$propertyPath}; 55 | } 56 | 57 | return $this->inner->getValue($objectOrArray, $propertyPath); 58 | } 59 | 60 | /** 61 | * @param array|object $objectOrArray 62 | */ 63 | public function isWritable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool 64 | { 65 | if ($objectOrArray instanceof Model) { 66 | return true; 67 | } 68 | 69 | return $this->inner->isWritable($objectOrArray, $propertyPath); 70 | } 71 | 72 | /** 73 | * @param array|object $objectOrArray 74 | */ 75 | public function isReadable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool 76 | { 77 | if ($objectOrArray instanceof Model) { 78 | return true; 79 | } 80 | 81 | return $this->inner->isReadable($objectOrArray, $propertyPath); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Eloquent/Serializer/SerializerContextBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\Serializer; 15 | 16 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 17 | use ApiPlatform\State\SerializerContextBuilderInterface; 18 | use Illuminate\Database\Eloquent\Model; 19 | use Symfony\Component\HttpFoundation\Request; 20 | use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; 21 | 22 | final class SerializerContextBuilder implements SerializerContextBuilderInterface 23 | { 24 | public function __construct( 25 | private readonly SerializerContextBuilderInterface $decorated, 26 | private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, 27 | ) { 28 | } 29 | 30 | /** 31 | * @param array $extractedAttributes 32 | */ 33 | public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array 34 | { 35 | $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); 36 | if (!isset($context['resource_class']) || !is_a($context['resource_class'], Model::class, true)) { 37 | return $context; 38 | } 39 | 40 | if (!isset($context[AbstractNormalizer::ATTRIBUTES])) { 41 | // isWritable/isReadable is checked later on 42 | $context[AbstractNormalizer::ATTRIBUTES] = iterator_to_array($this->propertyNameCollectionFactory->create($context['resource_class'], ['serializer_groups' => $context['groups'] ?? null])); 43 | } 44 | 45 | return $context; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Eloquent/State/CollectionProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; 17 | use ApiPlatform\Laravel\Eloquent\Paginator; 18 | use ApiPlatform\Laravel\Eloquent\PartialPaginator; 19 | use ApiPlatform\Metadata\Exception\RuntimeException; 20 | use ApiPlatform\Metadata\Operation; 21 | use ApiPlatform\State\Pagination\Pagination; 22 | use ApiPlatform\State\ProviderInterface; 23 | use ApiPlatform\State\Util\StateOptionsTrait; 24 | use Illuminate\Database\Eloquent\Collection; 25 | use Illuminate\Database\Eloquent\Model; 26 | use Psr\Container\ContainerInterface; 27 | 28 | /** 29 | * @implements ProviderInterface|PartialPaginator> 30 | */ 31 | final class CollectionProvider implements ProviderInterface 32 | { 33 | use LinksHandlerLocatorTrait; 34 | use StateOptionsTrait; 35 | 36 | /** 37 | * @param LinksHandlerInterface $linksHandler 38 | * @param iterable $queryExtensions 39 | */ 40 | public function __construct( 41 | private readonly Pagination $pagination, 42 | private readonly LinksHandlerInterface $linksHandler, 43 | private iterable $queryExtensions = [], 44 | ?ContainerInterface $handleLinksLocator = null, 45 | ) { 46 | $this->handleLinksLocator = $handleLinksLocator; 47 | } 48 | 49 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 50 | { 51 | $resourceClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); 52 | $model = new $resourceClass(); 53 | 54 | if (!$model instanceof Model) { 55 | throw new RuntimeException(\sprintf('The class "%s" is not an Eloquent model.', $resourceClass)); 56 | } 57 | 58 | if ($handleLinks = $this->getLinksHandler($operation)) { 59 | $query = $handleLinks($model->query(), $uriVariables, ['operation' => $operation, 'modelClass' => $operation->getClass()] + $context); 60 | } else { 61 | $query = $this->linksHandler->handleLinks($model->query(), $uriVariables, ['operation' => $operation, 'modelClass' => $operation->getClass()] + $context); 62 | } 63 | 64 | foreach ($this->queryExtensions as $extension) { 65 | $query = $extension->apply($query, $uriVariables, $operation, $context); 66 | } 67 | 68 | if ($order = $operation->getOrder()) { 69 | $isList = array_is_list($order); 70 | foreach ($order as $property => $direction) { 71 | if ($isList) { 72 | $property = $direction; 73 | $direction = 'ASC'; 74 | } 75 | 76 | if (str_contains($property, '.')) { 77 | [$table, $property] = explode('.', $property); 78 | 79 | // Relation Order by, we need to do laravel eager loading 80 | $query->with([ 81 | $table => fn ($query) => $query->orderBy($property, $direction), 82 | ]); 83 | 84 | continue; 85 | } 86 | 87 | $query->orderBy($property, $direction); 88 | } 89 | } 90 | 91 | if (false === $this->pagination->isEnabled($operation, $context)) { 92 | return $query->get(); 93 | } 94 | 95 | $isPartial = $operation->getPaginationPartial(); 96 | $collection = $query 97 | ->{$isPartial ? 'simplePaginate' : 'paginate'}( 98 | perPage: $this->pagination->getLimit($operation, $context), 99 | page: $this->pagination->getPage($context), 100 | ); 101 | 102 | if ($isPartial) { 103 | return new PartialPaginator($collection); 104 | } 105 | 106 | return new Paginator($collection); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Eloquent/State/ItemProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; 17 | use ApiPlatform\Metadata\Exception\RuntimeException; 18 | use ApiPlatform\Metadata\Operation; 19 | use ApiPlatform\State\ProviderInterface; 20 | use ApiPlatform\State\Util\StateOptionsTrait; 21 | use Illuminate\Database\Eloquent\Model; 22 | use Psr\Container\ContainerInterface; 23 | 24 | /** 25 | * @implements ProviderInterface 26 | */ 27 | final class ItemProvider implements ProviderInterface 28 | { 29 | use LinksHandlerLocatorTrait; 30 | use StateOptionsTrait; 31 | 32 | /** 33 | * @param LinksHandlerInterface $linksHandler 34 | * @param iterable $queryExtensions 35 | */ 36 | public function __construct( 37 | private readonly LinksHandlerInterface $linksHandler, 38 | ?ContainerInterface $handleLinksLocator = null, 39 | private iterable $queryExtensions = [], 40 | ) { 41 | $this->handleLinksLocator = $handleLinksLocator; 42 | } 43 | 44 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 45 | { 46 | $resourceClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); 47 | $model = new $resourceClass(); 48 | 49 | if (!$model instanceof Model) { 50 | throw new RuntimeException(\sprintf('The class "%s" is not an Eloquent model.', $resourceClass)); 51 | } 52 | 53 | if ($handleLinks = $this->getLinksHandler($operation)) { 54 | $query = $handleLinks($model->query(), $uriVariables, ['operation' => $operation] + $context); 55 | } else { 56 | $query = $this->linksHandler->handleLinks($model->query(), $uriVariables, ['operation' => $operation] + $context); 57 | } 58 | 59 | foreach ($this->queryExtensions as $extension) { 60 | $query = $extension->apply($query, $uriVariables, $operation, $context); 61 | } 62 | 63 | return $query->first(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Eloquent/State/LinksHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Metadata\Exception\OperationNotFoundException; 17 | use ApiPlatform\Metadata\GraphQl\Operation; 18 | use ApiPlatform\Metadata\GraphQl\Query; 19 | use ApiPlatform\Metadata\HttpOperation; 20 | use ApiPlatform\Metadata\Link; 21 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 22 | use Illuminate\Contracts\Foundation\Application; 23 | use Illuminate\Database\Eloquent\Builder; 24 | use Illuminate\Database\Eloquent\Model; 25 | 26 | /** 27 | * @implements LinksHandlerInterface 28 | */ 29 | final class LinksHandler implements LinksHandlerInterface 30 | { 31 | public function __construct( 32 | private readonly Application $application, 33 | private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, 34 | ) { 35 | } 36 | 37 | public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder 38 | { 39 | $operation = $context['operation']; 40 | 41 | if ($operation instanceof HttpOperation) { 42 | foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) { 43 | $builder = $this->buildQuery($builder, $link, $uriVariables[$uriVariable]); 44 | } 45 | 46 | return $builder; 47 | } 48 | 49 | if (!($linkClass = $context['linkClass'] ?? false)) { 50 | return $builder; 51 | } 52 | 53 | $newLink = null; 54 | $linkedOperation = null; 55 | $linkProperty = $context['linkProperty'] ?? null; 56 | 57 | try { 58 | $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass); 59 | $linkedOperation = $resourceMetadataCollection->getOperation($operation->getName()); 60 | } catch (OperationNotFoundException) { 61 | // Instead, we'll look for the first Query available. 62 | foreach ($resourceMetadataCollection as $resourceMetadata) { 63 | foreach ($resourceMetadata->getGraphQlOperations() as $op) { 64 | if ($op instanceof Query) { 65 | $linkedOperation = $op; 66 | } 67 | } 68 | } 69 | } 70 | 71 | if (!$linkedOperation instanceof Operation) { 72 | return $builder; 73 | } 74 | 75 | $resourceClass = $builder->getModel()::class; 76 | foreach ($linkedOperation->getLinks() ?? [] as $link) { 77 | if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) { 78 | $newLink = $link; 79 | break; 80 | } 81 | } 82 | 83 | if (!$newLink) { 84 | return $builder; 85 | } 86 | 87 | return $this->buildQuery($builder, $newLink, $uriVariables[$newLink->getIdentifiers()[0]]); 88 | } 89 | 90 | /** 91 | * @param Builder $builder 92 | * 93 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 94 | * 95 | * @return Builder $builder 96 | */ 97 | private function buildQuery(Builder $builder, Link $link, mixed $identifier): Builder 98 | { 99 | if ($to = $link->getToProperty()) { 100 | return $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier); 101 | } 102 | 103 | if ($from = $link->getFromProperty()) { 104 | $relation = $this->application->make($link->getFromClass()); 105 | $relationQuery = $relation->{$from}(); 106 | if (!method_exists($relationQuery, 'getQualifiedForeignKeyName') && method_exists($relationQuery, 'getQualifiedForeignPivotKeyName')) { 107 | return $builder->getModel() 108 | ->join( 109 | $relationQuery->getTable(), // @phpstan-ignore-line 110 | $relationQuery->getQualifiedRelatedPivotKeyName(), // @phpstan-ignore-line 111 | $builder->getModel()->getQualifiedKeyName() 112 | ) 113 | ->where( 114 | $relationQuery->getQualifiedForeignPivotKeyName(), // @phpstan-ignore-line 115 | $identifier 116 | ) 117 | ->select($builder->getModel()->getTable().'.*'); 118 | } 119 | 120 | if (method_exists($relationQuery, 'dissociate')) { 121 | return $builder->getModel() 122 | ->join( 123 | $relationQuery->getParent()->getTable(), // @phpstan-ignore-line 124 | $relationQuery->getParent()->getQualifiedKeyName(), // @phpstan-ignore-line 125 | $identifier 126 | ); 127 | } 128 | 129 | return $builder->getModel()->where($relationQuery->getQualifiedForeignKeyName(), $identifier); 130 | } 131 | 132 | return $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Eloquent/State/LinksHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Metadata\Operation; 17 | use Illuminate\Database\Eloquent\Builder; 18 | use Illuminate\Database\Eloquent\Model; 19 | 20 | /** 21 | * @template T of Model 22 | */ 23 | interface LinksHandlerInterface 24 | { 25 | /** 26 | * Handles Laravel links. 27 | * 28 | * @param Builder $builder 29 | * @param array $uriVariables 30 | * @param array{modelClass: string, operation: Operation}|array $context 31 | * 32 | * @return Builder 33 | */ 34 | public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder; 35 | } 36 | -------------------------------------------------------------------------------- /Eloquent/State/LinksHandlerLocatorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Metadata\Exception\RuntimeException; 17 | use ApiPlatform\Metadata\Operation; 18 | use Psr\Container\ContainerInterface; 19 | 20 | /** 21 | * @internal 22 | */ 23 | trait LinksHandlerLocatorTrait 24 | { 25 | private ?ContainerInterface $handleLinksLocator; 26 | 27 | private function getLinksHandler(Operation $operation): ?callable 28 | { 29 | if (!($options = $operation->getStateOptions()) || !$options instanceof Options || !$options->getHandleLinks()) { 30 | return null; 31 | } 32 | 33 | $handleLinks = $options->getHandleLinks(); 34 | if (\is_callable($handleLinks)) { 35 | return $handleLinks; 36 | } 37 | 38 | if ($this->handleLinksLocator && \is_string($handleLinks) && $this->handleLinksLocator->has($handleLinks)) { 39 | return [$this->handleLinksLocator->get($handleLinks), 'handleLinks']; // @phpstan-ignore-line 40 | } 41 | 42 | throw new RuntimeException(\sprintf('Could not find handleLinks service "%s"', $handleLinks)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Eloquent/State/Options.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\State\OptionsInterface; 17 | 18 | class Options implements OptionsInterface 19 | { 20 | /** 21 | * @param string|callable $handleLinks experimental callable, typed mixed as we may want a service name in the future 22 | * 23 | * @see LinksHandlerInterface 24 | */ 25 | public function __construct( 26 | protected ?string $modelClass = null, 27 | protected mixed $handleLinks = null, 28 | ) { 29 | } 30 | 31 | public function getHandleLinks(): mixed 32 | { 33 | return $this->handleLinks; 34 | } 35 | 36 | public function withHandleLinks(mixed $handleLinks): self 37 | { 38 | $self = clone $this; 39 | $self->handleLinks = $handleLinks; 40 | 41 | return $self; 42 | } 43 | 44 | public function getModelClass(): ?string 45 | { 46 | return $this->modelClass; 47 | } 48 | 49 | public function withModelClass(?string $modelClass): self 50 | { 51 | $self = clone $this; 52 | $self->modelClass = $modelClass; 53 | 54 | return $self; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Eloquent/State/PersistProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; 17 | use ApiPlatform\Metadata\Exception\RuntimeException; 18 | use ApiPlatform\Metadata\HttpOperation; 19 | use ApiPlatform\Metadata\Operation; 20 | use ApiPlatform\State\ProcessorInterface; 21 | use Illuminate\Database\Eloquent\Relations\BelongsTo; 22 | use Illuminate\Database\Eloquent\Relations\HasMany; 23 | 24 | /** 25 | * @implements ProcessorInterface<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> 26 | */ 27 | final class PersistProcessor implements ProcessorInterface 28 | { 29 | /** 30 | * @var array 31 | */ 32 | private array $relations; 33 | 34 | public function __construct( 35 | private readonly ModelMetadata $modelMetadata, 36 | ) { 37 | } 38 | 39 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) 40 | { 41 | $toMany = []; 42 | 43 | foreach ($this->modelMetadata->getRelations($data) as $relation) { 44 | if (!isset($data->{$relation['name']})) { 45 | continue; 46 | } 47 | 48 | if (BelongsTo::class === $relation['type']) { 49 | $rel = $data->{$relation['name']}; 50 | 51 | if (!$rel->exists) { 52 | $rel->save(); 53 | } 54 | 55 | $data->{$relation['method_name']}()->associate($data->{$relation['name']}); 56 | unset($data->{$relation['name']}); 57 | $this->relations[$relation['method_name']] = $relation['name']; 58 | } 59 | 60 | if (HasMany::class === $relation['type']) { 61 | $rel = $data->{$relation['name']}; 62 | 63 | if (!\is_array($rel)) { 64 | throw new RuntimeException('To-Many relationship is not a collection.'); 65 | } 66 | 67 | $toMany[$relation['method_name']] = $rel; 68 | unset($data->{$relation['name']}); 69 | $this->relations[$relation['method_name']] = $relation['name']; 70 | } 71 | } 72 | 73 | if (($previousData = $context['previous_data'] ?? null) && $operation instanceof HttpOperation && 'PUT' === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true)) { 74 | foreach ($this->modelMetadata->getAttributes($data) as $attribute) { 75 | if ($attribute['primary'] ?? false) { 76 | $data->{$attribute['name']} = $previousData->{$attribute['name']}; 77 | } 78 | } 79 | $data->exists = true; 80 | } 81 | 82 | $data->saveOrFail(); 83 | $data->refresh(); 84 | 85 | foreach ($data->getRelations() as $methodName => $obj) { 86 | if (isset($this->relations[$methodName])) { 87 | $data->{$this->relations[$methodName]} = $obj; 88 | } 89 | } 90 | 91 | foreach ($toMany as $methodName => $relations) { 92 | $data->{$methodName}()->saveMany($relations); 93 | $data->{$this->relations[$methodName]} = $relations; 94 | unset($toMany[$methodName]); 95 | } 96 | 97 | return $data; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Eloquent/State/RemoveProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Eloquent\State; 15 | 16 | use ApiPlatform\Metadata\Operation; 17 | use ApiPlatform\State\ProcessorInterface; 18 | 19 | /** 20 | * @implements ProcessorInterface<\Illuminate\Database\Eloquent\Model, null> 21 | */ 22 | final class RemoveProcessor implements ProcessorInterface 23 | { 24 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) 25 | { 26 | $data->delete(); 27 | 28 | return null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /GraphQl/Controller/GraphiQlController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\GraphQl\Controller; 15 | 16 | use Illuminate\Http\Response; 17 | 18 | readonly class GraphiQlController 19 | { 20 | public function __construct(private readonly string $prefix) 21 | { 22 | } 23 | 24 | public function __invoke(): Response 25 | { 26 | return new Response(view('api-platform::graphiql', ['graphiql_data' => ['entrypoint' => $this->prefix.'/graphql']]), 200); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /JsonApi/State/JsonApiProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\JsonApi\State; 15 | 16 | use ApiPlatform\Metadata\Operation; 17 | use ApiPlatform\State\ProviderInterface; 18 | 19 | /** 20 | * This is a copy of ApiPlatform\JsonApi\State\JsonApiProvider without the support of sort,filter and fields as these should be implemented using QueryParameters and specific Filters. 21 | * At some point we want to merge both classes but for now we don't have the SortFilter inside Symfony. 22 | * 23 | * @internal 24 | */ 25 | final class JsonApiProvider implements ProviderInterface 26 | { 27 | public function __construct(private readonly ProviderInterface $decorated) 28 | { 29 | } 30 | 31 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 32 | { 33 | $request = $context['request'] ?? null; 34 | 35 | if (!$request || 'jsonapi' !== $request->getRequestFormat()) { 36 | return $this->decorated->provide($operation, $uriVariables, $context); 37 | } 38 | 39 | $filters = $request->attributes->get('_api_filters', []); 40 | $queryParameters = $request->query->all(); 41 | 42 | $pageParameter = $queryParameters['page'] ?? null; 43 | if ( 44 | \is_array($pageParameter) 45 | ) { 46 | $filters = array_merge($pageParameter, $filters); 47 | } 48 | 49 | if (isset($pageParameter['offset'])) { 50 | $filters['page'] = $pageParameter['offset']; 51 | unset($filters['offset']); 52 | } 53 | 54 | $includeParameter = $queryParameters['include'] ?? null; 55 | 56 | if ($includeParameter) { 57 | $request->attributes->set('_api_included', explode(',', $includeParameter)); 58 | } 59 | 60 | if ($filters) { 61 | $request->attributes->set('_api_filters', $filters); 62 | } 63 | 64 | return $this->decorated->provide($operation, $uriVariables, $context); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2024-present Kévin Dunglas 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 furnished 10 | 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Metadata/CachePropertyMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Metadata; 15 | 16 | use ApiPlatform\Metadata\ApiProperty; 17 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 18 | use Illuminate\Support\Facades\Cache; 19 | 20 | final readonly class CachePropertyMetadataFactory implements PropertyMetadataFactoryInterface 21 | { 22 | public function __construct( 23 | private PropertyMetadataFactoryInterface $decorated, 24 | private string $cacheStore, 25 | ) { 26 | } 27 | 28 | public function create(string $resourceClass, string $property, array $options = []): ApiProperty 29 | { 30 | $key = hash('xxh3', serialize(['resource_class' => $resourceClass, 'property' => $property] + $options)); 31 | 32 | return Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $property, $options) { 33 | return $this->decorated->create($resourceClass, $property, $options); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Metadata/CachePropertyNameCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Metadata; 15 | 16 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 17 | use ApiPlatform\Metadata\Property\PropertyNameCollection; 18 | use Illuminate\Support\Facades\Cache; 19 | 20 | final readonly class CachePropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface 21 | { 22 | public function __construct( 23 | private PropertyNameCollectionFactoryInterface $decorated, 24 | private string $cacheStore, 25 | ) { 26 | } 27 | 28 | public function create(string $resourceClass, array $options = []): PropertyNameCollection 29 | { 30 | $key = hash('xxh3', serialize(['resource_class' => $resourceClass] + $options)); 31 | 32 | return Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $options) { 33 | return $this->decorated->create($resourceClass, $options); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Metadata/CacheResourceCollectionMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Metadata; 15 | 16 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 17 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 18 | use Illuminate\Support\Facades\Cache; 19 | 20 | final readonly class CacheResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface 21 | { 22 | public function __construct( 23 | private ResourceMetadataCollectionFactoryInterface $decorated, 24 | private string $cacheStore, 25 | ) { 26 | } 27 | 28 | public function create(string $resourceClass): ResourceMetadataCollection 29 | { 30 | return Cache::store($this->cacheStore)->rememberForever($resourceClass, function () use ($resourceClass) { 31 | return $this->decorated->create($resourceClass); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Metadata/ParameterValidationResourceMetadataCollectionFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Metadata; 15 | 16 | use ApiPlatform\Metadata\Parameter; 17 | use ApiPlatform\Metadata\Parameters; 18 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 19 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 20 | use Illuminate\Validation\Rule; 21 | use Psr\Container\ContainerInterface; 22 | 23 | final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface 24 | { 25 | public function __construct( 26 | private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, 27 | private readonly ?ContainerInterface $filterLocator = null, 28 | ) { 29 | } 30 | 31 | public function create(string $resourceClass): ResourceMetadataCollection 32 | { 33 | $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); 34 | 35 | foreach ($resourceMetadataCollection as $i => $resource) { 36 | $operations = $resource->getOperations(); 37 | 38 | foreach ($operations as $operationName => $operation) { 39 | $parameters = $operation->getParameters() ?? new Parameters(); 40 | foreach ($parameters as $key => $parameter) { 41 | $parameters->add($key, $this->addSchemaValidation($parameter)); 42 | } 43 | 44 | if (\count($parameters) > 0) { 45 | $operations->add($operationName, $operation->withParameters($parameters)); 46 | } 47 | } 48 | 49 | $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); 50 | 51 | if (!$graphQlOperations = $resource->getGraphQlOperations()) { 52 | continue; 53 | } 54 | 55 | foreach ($graphQlOperations as $operationName => $operation) { 56 | $parameters = $operation->getParameters() ?? new Parameters(); 57 | foreach ($operation->getParameters() ?? [] as $key => $parameter) { 58 | $parameters->add($key, $this->addSchemaValidation($parameter)); 59 | } 60 | 61 | if (\count($parameters) > 0) { 62 | $graphQlOperations[$operationName] = $operation->withParameters($parameters); 63 | } 64 | } 65 | 66 | $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); 67 | } 68 | 69 | return $resourceMetadataCollection; 70 | } 71 | 72 | private function addSchemaValidation(Parameter $parameter): Parameter 73 | { 74 | $schema = $parameter->getSchema(); 75 | $required = $parameter->getRequired(); 76 | $openApi = $parameter->getOpenApi(); 77 | 78 | // When it's an array of openapi parameters take the first one as it's probably just a variant of the query parameter, 79 | // only getAllowEmptyValue is used here anyways 80 | if (\is_array($openApi)) { 81 | $openApi = $openApi[0]; 82 | } 83 | $assertions = []; 84 | $allowEmptyValue = $openApi?->getAllowEmptyValue(); 85 | if ($required || (false === $required && false === $allowEmptyValue)) { 86 | $assertions[] = 'required'; 87 | } 88 | 89 | if (true === $allowEmptyValue) { 90 | $assertions[] = 'nullable'; 91 | } 92 | 93 | if (isset($schema['exclusiveMinimum'])) { 94 | $assertions[] = 'gt:'.$schema['exclusiveMinimum']; 95 | } 96 | 97 | if (isset($schema['exclusiveMaximum'])) { 98 | $assertions[] = 'lt:'.$schema['exclusiveMaximum']; 99 | } 100 | 101 | if (isset($schema['minimum'])) { 102 | $assertions[] = 'gte:'.$schema['minimum']; 103 | } 104 | 105 | if (isset($schema['maximum'])) { 106 | $assertions[] = 'lte:'.$schema['maximum']; 107 | } 108 | 109 | if (isset($schema['pattern'])) { 110 | $assertions[] = 'regex:'.$schema['pattern']; 111 | } 112 | 113 | $minLength = isset($schema['minLength']); 114 | $maxLength = isset($schema['maxLength']); 115 | 116 | if ($minLength && $maxLength) { 117 | $assertions[] = \sprintf('between:%s,%s', $schema['minLength'], $schema['maxLength']); 118 | } elseif ($minLength) { 119 | $assertions[] = 'min:'.$schema['minLength']; 120 | } elseif ($maxLength) { 121 | $assertions[] = 'max:'.$schema['maxLength']; 122 | } 123 | 124 | $minItems = isset($schema['minItems']); 125 | $maxItems = isset($schema['maxItems']); 126 | 127 | if ($minItems && $maxItems) { 128 | $assertions[] = \sprintf('between:%s,%s', $schema['minItems'], $schema['maxItems']); 129 | } elseif ($minItems) { 130 | $assertions[] = 'min:'.$schema['minItems']; 131 | } elseif ($maxItems) { 132 | $assertions[] = 'max:'.$schema['maxItems']; 133 | } 134 | 135 | if (isset($schema['multipleOf'])) { 136 | $assertions[] = 'multiple_of:'.$schema['multipleOf']; 137 | } 138 | 139 | if (isset($schema['enum'])) { 140 | $assertions[] = Rule::in($schema['enum']); 141 | } 142 | 143 | if (isset($schema['type']) && 'array' === $schema['type']) { 144 | $assertions[] = 'array'; 145 | } 146 | 147 | if (isset($schema['type']) && 'boolean' === $schema['type']) { 148 | $assertions[] = 'boolean'; 149 | } 150 | 151 | if (!$assertions) { 152 | return $parameter; 153 | } 154 | 155 | if (1 === \count($assertions)) { 156 | return $parameter->withConstraints($assertions[0]); 157 | } 158 | 159 | return $parameter->withConstraints($assertions); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Platform for Laravel 2 | 3 | Integration of [Laravel](https://laravel.com) and the Illuminate components with the [API Platform](https://api-platform.com) framework. 4 | 5 | [Documentation](https://api-platform.com/docs/laravel/) 6 | 7 | > [!CAUTION] 8 | > 9 | > This is a read-only sub split of `api-platform/core`, please 10 | > [report issues](https://github.com/api-platform/core/issues) and 11 | > [send Pull Requests](https://github.com/api-platform/core/pulls) 12 | > in the [core API Platform repository](https://github.com/api-platform/core). 13 | -------------------------------------------------------------------------------- /Routing/Router.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Routing; 15 | 16 | use ApiPlatform\Metadata\UrlGeneratorInterface; 17 | use Illuminate\Http\Request as LaravelRequest; 18 | use Illuminate\Routing\Router as BaseRouter; 19 | use Symfony\Component\HttpFoundation\Request; 20 | use Symfony\Component\Routing\Generator\UrlGenerator; 21 | use Symfony\Component\Routing\RequestContext; 22 | use Symfony\Component\Routing\RouteCollection; 23 | use Symfony\Component\Routing\RouterInterface; 24 | 25 | /** 26 | * Laravel router decorator. 27 | * 28 | * @author Kévin Dunglas 29 | */ 30 | final class Router implements RouterInterface, UrlGeneratorInterface 31 | { 32 | public const CONST_MAP = [ 33 | UrlGeneratorInterface::ABS_URL => RouterInterface::ABSOLUTE_URL, 34 | UrlGeneratorInterface::ABS_PATH => RouterInterface::ABSOLUTE_PATH, 35 | UrlGeneratorInterface::REL_PATH => RouterInterface::RELATIVE_PATH, 36 | UrlGeneratorInterface::NET_PATH => RouterInterface::NETWORK_PATH, 37 | ]; 38 | 39 | private RequestContext $context; 40 | 41 | public function __construct(private readonly BaseRouter $router, private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH) 42 | { 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function setContext(RequestContext $context): void 49 | { 50 | $this->context = $context; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function getContext(): RequestContext 57 | { 58 | return $this->context; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function getRouteCollection(): RouteCollection 65 | { 66 | /** @var \Illuminate\Routing\RouteCollection $routes */ 67 | $routes = $this->router->getRoutes(); 68 | 69 | return $routes->toSymfonyRouteCollection(); 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | * 75 | * @return array|array{_api_resource_class?: class-string|string, _api_operation_name?: string, uri_variables?: array} 76 | */ 77 | public function match(string $pathInfo): array 78 | { 79 | $request = LaravelRequest::create($pathInfo, Request::METHOD_GET); 80 | $route = $this->router->getRoutes()->match($request); 81 | 82 | return $route->defaults + ['uri_variables' => array_diff_key($route->parameters, $route->defaults)]; 83 | } 84 | 85 | /** 86 | * {@inheritdoc} 87 | * 88 | * @param array $parameters 89 | */ 90 | public function generate(string $name, array $parameters = [], ?int $referenceType = null): string 91 | { 92 | $routes = $this->getRouteCollection(); 93 | $generator = new UrlGenerator($routes, $this->getContext()); 94 | if (isset($parameters['_format']) && !str_starts_with($parameters['_format'], '.')) { 95 | $parameters['_format'] = '.'.$parameters['_format']; 96 | } 97 | 98 | return $generator->generate($name, $parameters, self::CONST_MAP[$referenceType ?? $this->urlGenerationStrategy]); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Routing/SkolemIriConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Routing; 15 | 16 | use ApiPlatform\Metadata\Exception\ItemNotFoundException; 17 | use ApiPlatform\Metadata\IriConverterInterface; 18 | use ApiPlatform\Metadata\Operation; 19 | use ApiPlatform\Metadata\UrlGeneratorInterface; 20 | 21 | /** 22 | * {@inheritdoc} 23 | * 24 | * @author Antoine Bluchet 25 | */ 26 | final class SkolemIriConverter implements IriConverterInterface 27 | { 28 | public const SKOLEM_URI_TEMPLATE = '/.well-known/genid/{id}'; 29 | 30 | /** 31 | * @var \SplObjectStorage 32 | */ 33 | private \SplObjectStorage $objectHashMap; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private array $classHashMap = []; 39 | 40 | public function __construct(private readonly Router $router) 41 | { 42 | $this->objectHashMap = new \SplObjectStorage(); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object 49 | { 50 | throw new ItemNotFoundException(\sprintf('Item not found for "%s".', $iri)); 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): string 57 | { 58 | $referenceType = $operation ? ($operation->getUrlGenerationStrategy() ?? $referenceType) : $referenceType; 59 | if (($isObject = \is_object($resource)) && $this->objectHashMap->contains($resource)) { 60 | return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$resource]], $referenceType); 61 | } 62 | 63 | if (\is_string($resource) && isset($this->classHashMap[$resource])) { 64 | return $this->router->generate('api_genid', ['id' => $this->classHashMap[$resource]], $referenceType); 65 | } 66 | 67 | $id = bin2hex(random_bytes(10)); 68 | 69 | if ($isObject) { 70 | $this->objectHashMap[$resource] = $id; 71 | } else { 72 | $this->classHashMap[$resource] = $id; 73 | } 74 | 75 | return $this->router->generate('api_genid', ['id' => $id], $referenceType); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Security/ResourceAccessChecker.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Security; 15 | 16 | use ApiPlatform\Laravel\Eloquent\Paginator; 17 | use ApiPlatform\Metadata\ResourceAccessCheckerInterface; 18 | use Illuminate\Support\Facades\Gate; 19 | 20 | class ResourceAccessChecker implements ResourceAccessCheckerInterface 21 | { 22 | public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool 23 | { 24 | return Gate::allows( 25 | $expression, 26 | $extraVariables['object'] instanceof Paginator ? 27 | $resourceClass : 28 | $extraVariables['object'] 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ServiceLocator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel; 15 | 16 | use Psr\Container\ContainerInterface; 17 | 18 | // TODO: template T ServiceLocator 19 | final class ServiceLocator implements ContainerInterface 20 | { 21 | private array $services = []; 22 | 23 | /** 24 | * @param array $services 25 | */ 26 | public function __construct(array $services = []) 27 | { 28 | foreach ($services as $key => $service) { 29 | $this->services[\is_string($key) ? $key : $service::class] = $service; 30 | } 31 | } 32 | 33 | public function get(string $id): mixed 34 | { 35 | return $this->services[$id] ?? null; 36 | } 37 | 38 | public function has(string $id): bool 39 | { 40 | return isset($this->services[$id]); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /State/AccessCheckerProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Metadata\HttpOperation; 17 | use ApiPlatform\Metadata\Operation; 18 | use ApiPlatform\Metadata\ResourceAccessCheckerInterface; 19 | use ApiPlatform\State\ProviderInterface; 20 | use Illuminate\Auth\Access\AuthorizationException; 21 | use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; 22 | 23 | /** 24 | * Allows access based on the ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface. 25 | * This implementation covers GraphQl and HTTP. 26 | * 27 | * @see ResourceAccessCheckerInterface 28 | * 29 | * @implements ProviderInterface 30 | */ 31 | final class AccessCheckerProvider implements ProviderInterface 32 | { 33 | /** 34 | * @param ProviderInterface $decorated 35 | */ 36 | public function __construct(private readonly ProviderInterface $decorated, private readonly ResourceAccessCheckerInterface $resourceAccessChecker) 37 | { 38 | } 39 | 40 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 41 | { 42 | $policy = $operation->getPolicy(); 43 | $message = $operation->getSecurityMessage(); 44 | 45 | $body = $this->decorated->provide($operation, $uriVariables, $context); 46 | if (null === $policy) { 47 | return $body; 48 | } 49 | 50 | $request = $context['request'] ?? null; 51 | 52 | $resourceAccessCheckerContext = [ 53 | 'object' => $body, 54 | 'request' => $request, 55 | 'operation' => $operation, 56 | ]; 57 | 58 | if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $policy, $resourceAccessCheckerContext)) { 59 | throw $operation instanceof HttpOperation ? new AuthorizationException($message ?? 'Access Denied.') : new AccessDeniedHttpException($message ?? 'Access Denied.'); 60 | } 61 | 62 | return $body; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /State/ParameterValidatorProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Metadata\Exception\RuntimeException; 17 | use ApiPlatform\Metadata\Operation; 18 | use ApiPlatform\State\ParameterNotFound; 19 | use ApiPlatform\State\ProviderInterface; 20 | use ApiPlatform\State\Util\ParameterParserTrait; 21 | use Illuminate\Support\Facades\Validator; 22 | use Illuminate\Validation\ValidationException; 23 | use Symfony\Component\HttpFoundation\Request; 24 | 25 | /** 26 | * Validates parameters using the Symfony validator. 27 | * 28 | * @implements ProviderInterface 29 | * 30 | * @experimental 31 | */ 32 | final class ParameterValidatorProvider implements ProviderInterface 33 | { 34 | use ParameterParserTrait; 35 | use ValidationErrorTrait; 36 | 37 | /** 38 | * @param ProviderInterface $decorated 39 | */ 40 | public function __construct( 41 | private readonly ?ProviderInterface $decorated = null, 42 | ) { 43 | } 44 | 45 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 46 | { 47 | if (!($request = $context['request'] ?? null) instanceof Request) { 48 | return $this->decorated->provide($operation, $uriVariables, $context); 49 | } 50 | 51 | $operation = $request->attributes->get('_api_operation') ?? $operation; 52 | if (!($operation->getQueryParameterValidationEnabled() ?? true)) { 53 | return $this->decorated->provide($operation, $uriVariables, $context); 54 | } 55 | 56 | $allConstraints = []; 57 | foreach ($operation->getParameters() ?? [] as $parameter) { 58 | if (!$constraints = $parameter->getConstraints()) { 59 | continue; 60 | } 61 | 62 | $key = $parameter->getKey(); 63 | if (null === $key) { 64 | throw new RuntimeException('A parameter must have a defined key.'); 65 | } 66 | 67 | $value = $parameter->getValue(); 68 | if ($value instanceof ParameterNotFound) { 69 | $value = null; 70 | } 71 | 72 | // Basically renames our key from order[:property] to order.* to assign the rule properly (see https://laravel.com/docs/11.x/validation#rule-in) 73 | if (str_contains($key, '[:property]')) { 74 | $k = str_replace('[:property]', '', $key); 75 | $allConstraints[$k.'.*'] = $constraints; 76 | continue; 77 | } 78 | 79 | $allConstraints[$key] = $constraints; 80 | } 81 | 82 | $validator = Validator::make($request->query->all(), $allConstraints); 83 | if ($validator->fails()) { 84 | throw $this->getValidationError($validator, new ValidationException($validator)); 85 | } 86 | 87 | return $this->decorated->provide($operation, $uriVariables, $context); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /State/SwaggerUiProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Metadata\Exception\RuntimeException; 17 | use ApiPlatform\Metadata\Operation; 18 | use ApiPlatform\Metadata\UrlGeneratorInterface; 19 | use ApiPlatform\OpenApi\OpenApi; 20 | use ApiPlatform\OpenApi\Options; 21 | use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; 22 | use ApiPlatform\State\ProcessorInterface; 23 | use Illuminate\Http\Response; 24 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 25 | 26 | /** 27 | * @internal 28 | * 29 | * @implements ProcessorInterface 30 | */ 31 | final class SwaggerUiProcessor implements ProcessorInterface 32 | { 33 | use NormalizeOperationNameTrait; 34 | 35 | /** 36 | * @param array $formats 37 | */ 38 | public function __construct( 39 | private readonly UrlGeneratorInterface $urlGenerator, 40 | private readonly NormalizerInterface $normalizer, 41 | private readonly Options $openApiOptions, 42 | private readonly array $formats = [], 43 | private readonly ?string $oauthClientId = null, 44 | private readonly ?string $oauthClientSecret = null, 45 | private readonly bool $oauthPkce = false, 46 | ) { 47 | } 48 | 49 | /** 50 | * @param OpenApi $openApi 51 | */ 52 | public function process(mixed $openApi, Operation $operation, array $uriVariables = [], array $context = []): Response 53 | { 54 | $request = $context['request'] ?? null; 55 | 56 | $swaggerContext = [ 57 | 'formats' => $this->formats, 58 | 'title' => $openApi->getInfo()->getTitle(), 59 | 'description' => $openApi->getInfo()->getDescription(), 60 | 'originalRoute' => $request->attributes->get('_api_original_route', $request->attributes->get('_route')), 61 | 'originalRouteParams' => $request->attributes->get('_api_original_route_params', $request->attributes->get('_route_params', [])), 62 | ]; 63 | 64 | $swaggerData = [ 65 | 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), 66 | 'spec' => $this->normalizer->normalize($openApi, 'json', []), 67 | 'oauth' => [ 68 | 'enabled' => $this->openApiOptions->getOAuthEnabled(), 69 | 'type' => $this->openApiOptions->getOAuthType(), 70 | 'flow' => $this->openApiOptions->getOAuthFlow(), 71 | 'tokenUrl' => $this->openApiOptions->getOAuthTokenUrl(), 72 | 'authorizationUrl' => $this->openApiOptions->getOAuthAuthorizationUrl(), 73 | 'redirectUrl' => $request->getSchemeAndHttpHost().'/vendor/api-platform/swagger-ui/oauth2-redirect.html', 74 | 'scopes' => $this->openApiOptions->getOAuthScopes(), 75 | 'clientId' => $this->oauthClientId, 76 | 'clientSecret' => $this->oauthClientSecret, 77 | 'pkce' => $this->oauthPkce, 78 | ], 79 | ]; 80 | 81 | $status = 200; 82 | $requestedOperation = $request?->attributes->get('_api_requested_operation') ?? null; 83 | if ($request->isMethodSafe() && $requestedOperation && $requestedOperation->getName()) { 84 | // TODO: what if the parameter is named something else then `id`? 85 | $swaggerData['id'] = ($request->attributes->get('_api_original_uri_variables') ?? [])['id'] ?? null; 86 | $swaggerData['queryParameters'] = $request->query->all(); 87 | 88 | $swaggerData['shortName'] = $requestedOperation->getShortName(); 89 | $swaggerData['operationId'] = $this->normalizeOperationName($requestedOperation->getName()); 90 | 91 | [$swaggerData['path'], $swaggerData['method']] = $this->getPathAndMethod($swaggerData); 92 | $status = $requestedOperation->getStatus() ?? $status; 93 | } 94 | 95 | return new Response(view('api-platform::swagger-ui', $swaggerContext + ['swagger_data' => $swaggerData]), 200); 96 | } 97 | 98 | /** 99 | * @param array $swaggerData 100 | * 101 | * @return array{0: string, 1: string} 102 | */ 103 | private function getPathAndMethod(array $swaggerData): array 104 | { 105 | foreach ($swaggerData['spec']['paths'] as $path => $operations) { 106 | foreach ($operations as $method => $operation) { 107 | if (($operation['operationId'] ?? null) === $swaggerData['operationId']) { 108 | return [$path, $method]; 109 | } 110 | } 111 | } 112 | 113 | throw new RuntimeException(\sprintf('The operation "%s" cannot be found in the Swagger specification.', $swaggerData['operationId'])); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /State/SwaggerUiProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Documentation\Documentation; 17 | use ApiPlatform\Metadata\Error; 18 | use ApiPlatform\Metadata\Get; 19 | use ApiPlatform\Metadata\HttpOperation; 20 | use ApiPlatform\Metadata\Operation; 21 | use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; 22 | use ApiPlatform\OpenApi\OpenApi; 23 | use ApiPlatform\State\ProviderInterface; 24 | 25 | /** 26 | * When an HTML request is sent we provide a swagger ui documentation. 27 | * 28 | * @implements ProviderInterface 29 | * 30 | * @internal 31 | */ 32 | final class SwaggerUiProvider implements ProviderInterface 33 | { 34 | /** 35 | * @param ProviderInterface $decorated 36 | */ 37 | public function __construct( 38 | private readonly ProviderInterface $decorated, 39 | private readonly OpenApiFactoryInterface $openApiFactory, 40 | private readonly bool $swaggerUiEnabled = true, 41 | ) { 42 | } 43 | 44 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 45 | { 46 | // We went through the DocumentationAction 47 | if (OpenApi::class === $operation->getClass()) { 48 | return $this->decorated->provide($operation, $uriVariables, $context); 49 | } 50 | 51 | if ( 52 | !($operation instanceof HttpOperation) 53 | || !($request = $context['request'] ?? null) 54 | || 'html' !== $request->getRequestFormat() 55 | || !$this->swaggerUiEnabled 56 | || true === ($operation->getExtraProperties()['_api_disable_swagger_provider'] ?? false) 57 | ) { 58 | return $this->decorated->provide($operation, $uriVariables, $context); 59 | } 60 | 61 | if (!$request->attributes->has('_api_requested_operation')) { 62 | $request->attributes->set('_api_requested_operation', $operation); 63 | } 64 | 65 | // We need to call our operation provider just in case it fails 66 | // when it fails we'll get an Error, and we'll fix the status accordingly 67 | // @see features/main/content_negotiation.feature:119 68 | // When requesting DocumentationAction or EntrypointAction with Accept: text/html we render SwaggerUi 69 | if (!$operation instanceof Error && Documentation::class !== $operation->getClass()) { 70 | $this->decorated->provide($operation, $uriVariables, $context); 71 | } 72 | 73 | $swaggerUiOperation = new Get( 74 | class: OpenApi::class, 75 | processor: 'api_platform.swagger_ui.processor', 76 | validate: false, 77 | read: false, 78 | write: true, // force write so that our processor gets called 79 | status: $operation->getStatus() 80 | ); 81 | 82 | // save our operation 83 | $request->attributes->set('_api_operation', $swaggerUiOperation); 84 | 85 | $data = $this->openApiFactory->__invoke([ 86 | 'base_url' => $request->getBaseUrl() ?: '/', 87 | 'filter_tags' => $request->query->all('filter_tags'), 88 | ]); 89 | $request->attributes->set('data', $data); 90 | 91 | return $data; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /State/ValidateProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Metadata\Error; 17 | use ApiPlatform\Metadata\Exception\RuntimeException; 18 | use ApiPlatform\Metadata\Operation; 19 | use ApiPlatform\State\ProviderInterface; 20 | use Illuminate\Contracts\Foundation\Application; 21 | use Illuminate\Database\Eloquent\Model; 22 | use Illuminate\Foundation\Http\FormRequest; 23 | use Illuminate\Support\Facades\Validator; 24 | use Illuminate\Validation\ValidationException; 25 | use Symfony\Component\Serializer\NameConverter\NameConverterInterface; 26 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 27 | 28 | /** 29 | * @implements ProviderInterface 30 | */ 31 | final class ValidateProvider implements ProviderInterface 32 | { 33 | use ValidationErrorTrait; 34 | 35 | /** 36 | * @param ProviderInterface $inner 37 | */ 38 | public function __construct( 39 | private readonly ProviderInterface $inner, 40 | private readonly Application $app, 41 | // TODO: trigger deprecation in API Platform 4.2 when this is not defined 42 | private readonly ?NormalizerInterface $normalizer = null, 43 | ?NameConverterInterface $nameConverter = null, 44 | ) { 45 | $this->nameConverter = $nameConverter; 46 | } 47 | 48 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 49 | { 50 | $body = $this->inner->provide($operation, $uriVariables, $context); 51 | 52 | if ($operation instanceof Error) { 53 | return $body; 54 | } 55 | 56 | $rules = $operation->getRules(); 57 | if (\is_callable($rules)) { 58 | $rules = $rules(); 59 | } 60 | 61 | if (\is_string($rules) && is_a($rules, FormRequest::class, true)) { 62 | try { 63 | // this also throws an AuthorizationException 64 | $this->app->make($rules); 65 | } catch (ValidationException $e) { // @phpstan-ignore-line make->($rules) may throw this 66 | if (!$operation->canValidate()) { 67 | return $body; 68 | } 69 | 70 | throw $this->getValidationError($e->validator, $e); 71 | } 72 | 73 | return $body; 74 | } 75 | 76 | if (!$operation->canValidate()) { 77 | return $body; 78 | } 79 | 80 | if (!\is_array($rules)) { 81 | return $body; 82 | } 83 | 84 | $validationBody = $this->getBodyForValidation($body); 85 | 86 | $validator = Validator::make($validationBody, $rules); 87 | if ($validator->fails()) { 88 | throw $this->getValidationError($validator, new ValidationException($validator)); 89 | } 90 | 91 | return $body; 92 | } 93 | 94 | /** 95 | * @return array 96 | */ 97 | private function getBodyForValidation(mixed $body): array 98 | { 99 | if (!$body) { 100 | return []; 101 | } 102 | 103 | if ($body instanceof Model) { 104 | return $body->toArray(); 105 | } 106 | 107 | if ($this->normalizer) { 108 | if (!\is_array($v = $this->normalizer->normalize($body))) { 109 | throw new RuntimeException('An array is expected.'); 110 | } 111 | 112 | return $v; 113 | } 114 | 115 | // hopefully this path never gets used, its there for BC-layer only 116 | // TODO: deprecation in API Platform 4.2 117 | // TODO: remove in 5.0 118 | if ($s = json_encode($body)) { 119 | return json_decode($s, true); 120 | } 121 | 122 | throw new RuntimeException('Could not transform the denormalized body in an array for validation'); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /State/ValidationErrorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\State; 15 | 16 | use ApiPlatform\Laravel\ApiResource\ValidationError; 17 | use Illuminate\Contracts\Validation\Validator; 18 | use Illuminate\Validation\ValidationException; 19 | use Symfony\Component\Serializer\NameConverter\NameConverterInterface; 20 | 21 | trait ValidationErrorTrait 22 | { 23 | private ?NameConverterInterface $nameConverter = null; 24 | 25 | private function getValidationError(Validator $validator, ValidationException $e): ValidationError 26 | { 27 | $errors = $validator->errors(); 28 | $violations = []; 29 | $id = hash('xxh3', implode(',', $errors->keys())); 30 | foreach ($errors->messages() as $prop => $message) { 31 | $violations[] = ['propertyPath' => $this->nameConverter ? $this->nameConverter->normalize($prop) : $prop, 'message' => implode(\PHP_EOL, $message)]; 32 | } 33 | 34 | return new ValidationError($e->getMessage(), $id, $e, $violations); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Test/ApiTestAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Test; 15 | 16 | use ApiPlatform\Laravel\Test\Constraint\ArraySubset; 17 | use ApiPlatform\Metadata\IriConverterInterface; 18 | use PHPUnit\Framework\ExpectationFailedException; 19 | 20 | trait ApiTestAssertionsTrait 21 | { 22 | /** 23 | * Asserts that an array has a specified subset. 24 | * 25 | * Imported from dms/phpunit-arraysubset, because the original constraint has been deprecated. 26 | * 27 | * @copyright Sebastian Bergmann 28 | * @copyright Rafael Dohms 29 | * 30 | * @see https://github.com/sebastianbergmann/phpunit/issues/3494 31 | * 32 | * @param array $subset 33 | * @param array $array 34 | * 35 | * @throws ExpectationFailedException 36 | * @throws \Exception 37 | */ 38 | public static function assertArraySubset(iterable $subset, iterable $array, bool $checkForObjectIdentity = false, string $message = ''): void 39 | { 40 | $constraint = new ArraySubset($subset, $checkForObjectIdentity); 41 | 42 | static::assertThat($array, $constraint, $message); 43 | } 44 | 45 | /** 46 | * Asserts that the retrieved JSON contains the specified subset. 47 | * 48 | * This method delegates to static::assertArraySubset(). 49 | * 50 | * @param array $subset 51 | * @param array $json 52 | */ 53 | public static function assertJsonContains(array|string $subset, array $json, bool $checkForObjectIdentity = true, string $message = ''): void 54 | { 55 | if (\is_string($subset)) { 56 | $subset = json_decode($subset, true, 512, \JSON_THROW_ON_ERROR); 57 | } 58 | if (!\is_array($subset)) { 59 | throw new \InvalidArgumentException('$subset must be array or string (JSON array or JSON object)'); 60 | } 61 | 62 | static::assertArraySubset($subset, $json, $checkForObjectIdentity, $message); 63 | } 64 | 65 | /** 66 | * Generates the IRI of a resource item. 67 | */ 68 | protected function getIriFromResource(object $resource): ?string 69 | { 70 | $iriConverter = $this->app->make(IriConverterInterface::class); 71 | 72 | return $iriConverter->getIriFromResource($resource); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Test/Constraint/ArraySubset.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Test\Constraint; 15 | 16 | use PHPUnit\Framework\Constraint\Constraint; 17 | 18 | /** 19 | * Is used for phpunit >= 9. 20 | * 21 | * @internal 22 | */ 23 | final class ArraySubset extends Constraint 24 | { 25 | use ArraySubsetTrait; 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function evaluate(mixed $other, string $description = '', bool $returnResult = false): ?bool 31 | { 32 | return $this->_evaluate($other, $description, $returnResult); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Test/Constraint/ArraySubsetTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\Laravel\Test\Constraint; 15 | 16 | use SebastianBergmann\Comparator\ComparisonFailure; 17 | use SebastianBergmann\Exporter\Exporter; 18 | 19 | /** 20 | * Constraint that asserts that the array it is evaluated for has a specified subset. 21 | * 22 | * Uses array_replace_recursive() to check if a key value subset is part of the 23 | * subject array. 24 | * 25 | * Imported from dms/phpunit-arraysubset-asserts, because the original constraint has been deprecated. 26 | * 27 | * @copyright Sebastian Bergmann 28 | * @copyright Rafael Dohms 29 | * 30 | * @see https://github.com/sebastianbergmann/phpunit/issues/3494 31 | */ 32 | trait ArraySubsetTrait 33 | { 34 | /** 35 | * @param array $subset 36 | */ 37 | public function __construct(private iterable $subset, private readonly bool $strict = false) 38 | { 39 | } 40 | 41 | private function _evaluate(mixed $other, string $description = '', bool $returnResult = false): ?bool 42 | { 43 | // type cast $other & $this->subset as an array to allow 44 | // support in standard array functions. 45 | $other = $this->toArray($other); 46 | $this->subset = $this->toArray($this->subset); 47 | $patched = array_replace_recursive($other, $this->subset); 48 | if ($this->strict) { 49 | $result = $other === $patched; 50 | } else { 51 | $result = $other == $patched; 52 | } 53 | if ($returnResult) { 54 | return $result; 55 | } 56 | if ($result) { 57 | return null; 58 | } 59 | 60 | $f = new ComparisonFailure( 61 | $patched, 62 | $other, 63 | var_export($patched, true), 64 | var_export($other, true) 65 | ); 66 | $this->fail($other, $description, $f); 67 | } 68 | 69 | /** 70 | * {@inheritdoc} 71 | */ 72 | public function toString(): string 73 | { 74 | return 'has the subset '.(new Exporter())->export($this->subset); 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | protected function failureDescription(mixed $other): string 81 | { 82 | return 'an array '.$this->toString(); 83 | } 84 | 85 | /** 86 | * @param array $other 87 | * 88 | * @return array 89 | */ 90 | private function toArray(iterable $other): array 91 | { 92 | if (\is_array($other)) { 93 | return $other; 94 | } 95 | if ($other instanceof \ArrayObject) { 96 | return $other->getArrayCopy(); 97 | } 98 | 99 | return iterator_to_array($other); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-platform/laravel", 3 | "description": "API Platform support for Laravel", 4 | "keywords": [ 5 | "Laravel", 6 | "REST", 7 | "GraphQL", 8 | "API", 9 | "JSON-LD", 10 | "Hydra", 11 | "JSONAPI", 12 | "OpenAPI", 13 | "HAL", 14 | "Swagger" 15 | ], 16 | "homepage": "https://api-platform.com", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Kévin Dunglas", 21 | "email": "kevin@dunglas.fr", 22 | "homepage": "https://dunglas.fr" 23 | }, 24 | { 25 | "name": "API Platform Community", 26 | "homepage": "https://api-platform.com/community/contributors" 27 | } 28 | ], 29 | "require": { 30 | "php": ">=8.2", 31 | "api-platform/documentation": "^4.1.11", 32 | "api-platform/hydra": "^4.1.11", 33 | "api-platform/json-hal": "^4.1.11", 34 | "api-platform/json-schema": "^4.1.11", 35 | "api-platform/jsonld": "^4.1.11", 36 | "api-platform/json-api": "^4.1.11", 37 | "api-platform/metadata": "^4.1.11", 38 | "api-platform/openapi": "^4.1.11", 39 | "api-platform/serializer": "^4.1.11", 40 | "api-platform/state": "^4.1.11", 41 | "illuminate/config": "^11.0 || ^12.0", 42 | "laravel/framework": "^11.0 || ^12.0", 43 | "illuminate/contracts": "^11.0 || ^12.0", 44 | "illuminate/database": "^11.0 || ^12.0", 45 | "illuminate/http": "^11.0 || ^12.0", 46 | "illuminate/pagination": "^11.0 || ^12.0", 47 | "illuminate/routing": "^11.0 || ^12.0", 48 | "illuminate/support": "^11.0 || ^12.0", 49 | "illuminate/container": "^11.0 || ^12.0", 50 | "symfony/type-info": "^7.2", 51 | "symfony/web-link": "^6.4 || ^7.1", 52 | "willdurand/negotiation": "^3.1", 53 | "phpstan/phpdoc-parser": "^1.29 || ^2.0", 54 | "phpdocumentor/reflection-docblock": "^5.1" 55 | }, 56 | "require-dev": { 57 | "doctrine/dbal": "^4.0", 58 | "larastan/larastan": "^2.0 || ^3.0", 59 | "orchestra/testbench": "^9.1", 60 | "phpunit/phpunit": "11.5.x-dev", 61 | "api-platform/graphql": "^4.1", 62 | "laravel/sanctum": "^4.0" 63 | }, 64 | "autoload": { 65 | "psr-4": { 66 | "ApiPlatform\\Laravel\\": "" 67 | }, 68 | "exclude-from-classmap": [ 69 | "/Tests/", 70 | "/workbench/" 71 | ] 72 | }, 73 | "config": { 74 | "sort-packages": true 75 | }, 76 | "suggest": { 77 | "api-platform/graphql": "Enable GraphQl support.", 78 | "phpdocumentor/reflection-docblock": "" 79 | }, 80 | "extra": { 81 | "laravel": { 82 | "providers": [ 83 | "ApiPlatform\\Laravel\\ApiPlatformProvider", 84 | "ApiPlatform\\Laravel\\ApiPlatformDeferredProvider" 85 | ] 86 | }, 87 | "branch-alias": { 88 | "dev-main": "4.2.x-dev", 89 | "dev-3.4": "3.4.x-dev" 90 | }, 91 | "symfony": { 92 | "require": "^6.4 || ^7.1" 93 | }, 94 | "thanks": { 95 | "name": "api-platform/api-platform", 96 | "url": "https://github.com/api-platform/api-platform" 97 | } 98 | }, 99 | "autoload-dev": { 100 | "psr-4": { 101 | "Workbench\\App\\": "workbench/app/", 102 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 103 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 104 | } 105 | }, 106 | "scripts": { 107 | "build": "@php vendor/bin/testbench workbench:build --ansi", 108 | "test": "@php vendor/bin/testbench package:test", 109 | "post-autoload-dump": [ 110 | "@clear", 111 | "@prepare" 112 | ], 113 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 114 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 115 | "serve": [ 116 | "Composer\\Config::disableProcessTimeout", 117 | "@build", 118 | "@php vendor/bin/testbench serve --ansi" 119 | ], 120 | "lint": [ 121 | "@php vendor/bin/phpstan analyse --verbose --ansi" 122 | ] 123 | }, 124 | "repositories": [ 125 | { 126 | "type": "vcs", 127 | "url": "https://github.com/soyuka/phpunit" 128 | } 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /config/api-platform.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | use ApiPlatform\Metadata\UrlGeneratorInterface; 15 | use Illuminate\Auth\Access\AuthorizationException; 16 | use Illuminate\Auth\AuthenticationException; 17 | use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; 18 | 19 | return [ 20 | 'title' => 'API Platform', 21 | 'description' => 'My awesome API', 22 | 'version' => '1.0.0', 23 | 'show_webby' => true, 24 | 25 | 'routes' => [ 26 | 'domain' => null, 27 | // Global middleware applied to every API Platform routes 28 | // 'middleware' => [] 29 | ], 30 | 31 | 'resources' => [ 32 | app_path('Models'), 33 | ], 34 | 35 | 'formats' => [ 36 | 'jsonld' => ['application/ld+json'], 37 | // 'jsonapi' => ['application/vnd.api+json'], 38 | // 'csv' => ['text/csv'], 39 | ], 40 | 41 | 'patch_formats' => [ 42 | 'json' => ['application/merge-patch+json'], 43 | ], 44 | 45 | 'docs_formats' => [ 46 | 'jsonld' => ['application/ld+json'], 47 | // 'jsonapi' => ['application/vnd.api+json'], 48 | 'jsonopenapi' => ['application/vnd.openapi+json'], 49 | 'html' => ['text/html'], 50 | ], 51 | 52 | 'error_formats' => [ 53 | 'jsonproblem' => ['application/problem+json'], 54 | ], 55 | 56 | 'defaults' => [ 57 | 'pagination_enabled' => true, 58 | 'pagination_partial' => false, 59 | 'pagination_client_enabled' => false, 60 | 'pagination_client_items_per_page' => false, 61 | 'pagination_client_partial' => false, 62 | 'pagination_items_per_page' => 30, 63 | 'pagination_maximum_items_per_page' => 30, 64 | 'route_prefix' => '/api', 65 | 'middleware' => [], 66 | ], 67 | 68 | 'pagination' => [ 69 | 'page_parameter_name' => 'page', 70 | 'enabled_parameter_name' => 'pagination', 71 | 'items_per_page_parameter_name' => 'itemsPerPage', 72 | 'partial_parameter_name' => 'partial', 73 | ], 74 | 75 | 'graphql' => [ 76 | 'enabled' => false, 77 | 'nesting_separator' => '__', 78 | 'introspection' => ['enabled' => true], 79 | 'max_query_complexity' => 500, 80 | 'max_query_depth' => 200, 81 | // 'middleware' => null 82 | ], 83 | 84 | 'graphiql' => [ 85 | // 'enabled' => true, 86 | // 'domain' => null, 87 | // 'middleware' => null 88 | ], 89 | 90 | // set to null if you want to keep snake_case 91 | 'name_converter' => SnakeCaseToCamelCaseNameConverter::class, 92 | 93 | 'exception_to_status' => [ 94 | AuthenticationException::class => 401, 95 | AuthorizationException::class => 403, 96 | ], 97 | 98 | 'swagger_ui' => [ 99 | 'enabled' => true, 100 | // 'apiKeys' => [ 101 | // 'api' => [ 102 | // 'type' => 'Bearer', 103 | // 'name' => 'Authentication Token', 104 | // 'in' => 'header' 105 | // ] 106 | // ], 107 | // 'oauth' => [ 108 | // 'enabled' => true, 109 | // 'type' => 'oauth2', 110 | // 'flow' => 'authorizationCode', 111 | // 'tokenUrl' => '', 112 | // 'authorizationUrl' =>'', 113 | // 'refreshUrl' => '', 114 | // 'scopes' => ['scope1' => 'Description scope 1'], 115 | // 'pkce' => true 116 | // ], 117 | // 'license' => [ 118 | // 'name' => 'Apache 2.0', 119 | // 'url' => 'https://www.apache.org/licenses/LICENSE-2.0.html', 120 | // ], 121 | // 'contact' => [ 122 | // 'name' => 'API Support', 123 | // 'url' => 'https://www.example.com/support', 124 | // 'email' => 'support@example.com', 125 | // ], 126 | // 'http_auth' => [ 127 | // 'Personal Access Token' => [ 128 | // 'scheme' => 'bearer', 129 | // 'bearerFormat' => 'JWT' 130 | // ] 131 | // ] 132 | ], 133 | 134 | // 'openapi' => [ 135 | // 'tags' => [] 136 | // ], 137 | 138 | 'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH, 139 | 140 | 'serializer' => [ 141 | 'hydra_prefix' => false, 142 | // 'datetime_format' => \DateTimeInterface::RFC3339 143 | ], 144 | 145 | // we recommend using "file" or "acpu" 146 | 'cache' => 'file', 147 | ]; 148 | -------------------------------------------------------------------------------- /public/400.css: -------------------------------------------------------------------------------- 1 | /* open-sans-cyrillic-ext-400-normal */ 2 | @font-face { 3 | font-family: 'Open Sans'; 4 | font-style: normal; 5 | font-display: swap; 6 | font-weight: 400; 7 | src: url(./files/open-sans-cyrillic-ext-400-normal.woff2) format('woff2'), url(./files/open-sans-cyrillic-ext-400-normal.woff) format('woff'); 8 | unicode-range: U+0460-052F,U+1C80-1C88,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; 9 | } 10 | 11 | /* open-sans-cyrillic-400-normal */ 12 | @font-face { 13 | font-family: 'Open Sans'; 14 | font-style: normal; 15 | font-display: swap; 16 | font-weight: 400; 17 | src: url(./files/open-sans-cyrillic-400-normal.woff2) format('woff2'), url(./files/open-sans-cyrillic-400-normal.woff) format('woff'); 18 | unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; 19 | } 20 | 21 | /* open-sans-greek-ext-400-normal */ 22 | @font-face { 23 | font-family: 'Open Sans'; 24 | font-style: normal; 25 | font-display: swap; 26 | font-weight: 400; 27 | src: url(./files/open-sans-greek-ext-400-normal.woff2) format('woff2'), url(./files/open-sans-greek-ext-400-normal.woff) format('woff'); 28 | unicode-range: U+1F00-1FFF; 29 | } 30 | 31 | /* open-sans-greek-400-normal */ 32 | @font-face { 33 | font-family: 'Open Sans'; 34 | font-style: normal; 35 | font-display: swap; 36 | font-weight: 400; 37 | src: url(./files/open-sans-greek-400-normal.woff2) format('woff2'), url(./files/open-sans-greek-400-normal.woff) format('woff'); 38 | unicode-range: U+0370-03FF; 39 | } 40 | 41 | /* open-sans-hebrew-400-normal */ 42 | @font-face { 43 | font-family: 'Open Sans'; 44 | font-style: normal; 45 | font-display: swap; 46 | font-weight: 400; 47 | src: url(./files/open-sans-hebrew-400-normal.woff2) format('woff2'), url(./files/open-sans-hebrew-400-normal.woff) format('woff'); 48 | unicode-range: U+0590-05FF,U+200C-2010,U+20AA,U+25CC,U+FB1D-FB4F; 49 | } 50 | 51 | /* open-sans-vietnamese-400-normal */ 52 | @font-face { 53 | font-family: 'Open Sans'; 54 | font-style: normal; 55 | font-display: swap; 56 | font-weight: 400; 57 | src: url(./files/open-sans-vietnamese-400-normal.woff2) format('woff2'), url(./files/open-sans-vietnamese-400-normal.woff) format('woff'); 58 | unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; 59 | } 60 | 61 | /* open-sans-latin-ext-400-normal */ 62 | @font-face { 63 | font-family: 'Open Sans'; 64 | font-style: normal; 65 | font-display: swap; 66 | font-weight: 400; 67 | src: url(./files/open-sans-latin-ext-400-normal.woff2) format('woff2'), url(./files/open-sans-latin-ext-400-normal.woff) format('woff'); 68 | unicode-range: U+0100-02AF,U+0304,U+0308,U+0329,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF; 69 | } 70 | 71 | /* open-sans-latin-400-normal */ 72 | @font-face { 73 | font-family: 'Open Sans'; 74 | font-style: normal; 75 | font-display: swap; 76 | font-weight: 400; 77 | src: url(./files/open-sans-latin-400-normal.woff2) format('woff2'), url(./files/open-sans-latin-400-normal.woff) format('woff'); 78 | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 79 | } -------------------------------------------------------------------------------- /public/700.css: -------------------------------------------------------------------------------- 1 | /* open-sans-cyrillic-ext-700-normal */ 2 | @font-face { 3 | font-family: 'Open Sans'; 4 | font-style: normal; 5 | font-display: swap; 6 | font-weight: 700; 7 | src: url(./files/open-sans-cyrillic-ext-700-normal.woff2) format('woff2'), url(./files/open-sans-cyrillic-ext-700-normal.woff) format('woff'); 8 | unicode-range: U+0460-052F,U+1C80-1C88,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; 9 | } 10 | 11 | /* open-sans-cyrillic-700-normal */ 12 | @font-face { 13 | font-family: 'Open Sans'; 14 | font-style: normal; 15 | font-display: swap; 16 | font-weight: 700; 17 | src: url(./files/open-sans-cyrillic-700-normal.woff2) format('woff2'), url(./files/open-sans-cyrillic-700-normal.woff) format('woff'); 18 | unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; 19 | } 20 | 21 | /* open-sans-greek-ext-700-normal */ 22 | @font-face { 23 | font-family: 'Open Sans'; 24 | font-style: normal; 25 | font-display: swap; 26 | font-weight: 700; 27 | src: url(./files/open-sans-greek-ext-700-normal.woff2) format('woff2'), url(./files/open-sans-greek-ext-700-normal.woff) format('woff'); 28 | unicode-range: U+1F00-1FFF; 29 | } 30 | 31 | /* open-sans-greek-700-normal */ 32 | @font-face { 33 | font-family: 'Open Sans'; 34 | font-style: normal; 35 | font-display: swap; 36 | font-weight: 700; 37 | src: url(./files/open-sans-greek-700-normal.woff2) format('woff2'), url(./files/open-sans-greek-700-normal.woff) format('woff'); 38 | unicode-range: U+0370-03FF; 39 | } 40 | 41 | /* open-sans-hebrew-700-normal */ 42 | @font-face { 43 | font-family: 'Open Sans'; 44 | font-style: normal; 45 | font-display: swap; 46 | font-weight: 700; 47 | src: url(./files/open-sans-hebrew-700-normal.woff2) format('woff2'), url(./files/open-sans-hebrew-700-normal.woff) format('woff'); 48 | unicode-range: U+0590-05FF,U+200C-2010,U+20AA,U+25CC,U+FB1D-FB4F; 49 | } 50 | 51 | /* open-sans-vietnamese-700-normal */ 52 | @font-face { 53 | font-family: 'Open Sans'; 54 | font-style: normal; 55 | font-display: swap; 56 | font-weight: 700; 57 | src: url(./files/open-sans-vietnamese-700-normal.woff2) format('woff2'), url(./files/open-sans-vietnamese-700-normal.woff) format('woff'); 58 | unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; 59 | } 60 | 61 | /* open-sans-latin-ext-700-normal */ 62 | @font-face { 63 | font-family: 'Open Sans'; 64 | font-style: normal; 65 | font-display: swap; 66 | font-weight: 700; 67 | src: url(./files/open-sans-latin-ext-700-normal.woff2) format('woff2'), url(./files/open-sans-latin-ext-700-normal.woff) format('woff'); 68 | unicode-range: U+0100-02AF,U+0304,U+0308,U+0329,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF; 69 | } 70 | 71 | /* open-sans-latin-700-normal */ 72 | @font-face { 73 | font-family: 'Open Sans'; 74 | font-style: normal; 75 | font-display: swap; 76 | font-weight: 700; 77 | src: url(./files/open-sans-latin-700-normal.woff2) format('woff2'), url(./files/open-sans-latin-700-normal.woff) format('woff'); 78 | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 79 | } -------------------------------------------------------------------------------- /public/es6-promise/es6-promise.auto.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.ES6Promise=e()}(this,function(){"use strict";function t(t){var e=typeof t;return null!==t&&("object"===e||"function"===e)}function e(t){return"function"==typeof t}function n(t){B=t}function r(t){G=t}function o(){return function(){return process.nextTick(a)}}function i(){return"undefined"!=typeof z?function(){z(a)}:c()}function s(){var t=0,e=new J(a),n=document.createTextNode("");return e.observe(n,{characterData:!0}),function(){n.data=t=++t%2}}function u(){var t=new MessageChannel;return t.port1.onmessage=a,function(){return t.port2.postMessage(0)}}function c(){var t=setTimeout;return function(){return t(a,1)}}function a(){for(var t=0;tli.selected, 110 | #graphiql .graphiql-container .toolbar-menu-items>li.hover, 111 | #graphiql.graphiql-container .toolbar-menu-items>li:active, 112 | #graphiql .graphiql-container .toolbar-menu-items>li:hover, 113 | #graphiql.graphiql-container .toolbar-select-options>li.hover, 114 | #graphiql .graphiql-container .toolbar-select-options>li:active, 115 | #graphiql .graphiql-container .toolbar-select-options>li:hover, 116 | #graphiql .graphiql-container .history-contents>p:hover, 117 | #graphiql .graphiql-container .history-contents>p:active { 118 | background: #288690; 119 | } 120 | -------------------------------------------------------------------------------- /public/graphql-playground/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | overflow: hidden; 6 | } 7 | 8 | #root { 9 | height: 100%; 10 | } 11 | 12 | body { 13 | font-family: 'Open Sans', sans-serif; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | color: rgba(0,0,0,.8); 17 | line-height: 1.5; 18 | height: 100vh; 19 | letter-spacing: 0.53px; 20 | margin-right: -1px !important; 21 | } 22 | 23 | html, body, p, a, h1, h2, h3, h4, ul, pre, code { 24 | margin: 0; 25 | padding: 0; 26 | color: inherit; 27 | } 28 | 29 | a:active, a:focus, button:focus, input:focus { 30 | outline: none; 31 | } 32 | 33 | input, button, submit { 34 | border: none; 35 | } 36 | 37 | input, button, pre { 38 | font-family: 'Open Sans', sans-serif; 39 | } 40 | 41 | code { 42 | font-family: Consolas, monospace; 43 | } 44 | 45 | /*# sourceMappingURL=index.css.map*/ -------------------------------------------------------------------------------- /public/init-common-ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const graphiQlLink = document.querySelector('.graphiql-link'); 4 | if (graphiQlLink) { 5 | graphiQlLink.addEventListener('click', e => { 6 | if (!e.target.hasAttribute('href')) { 7 | alert('GraphQL support is not enabled, see https://api-platform.com/docs/core/graphql/'); 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /public/init-graphiql.js: -------------------------------------------------------------------------------- 1 | var initParameters = {}; 2 | var entrypoint = null; 3 | 4 | function onEditQuery(newQuery) { 5 | initParameters.query = newQuery; 6 | updateURL(); 7 | } 8 | 9 | function onEditVariables(newVariables) { 10 | initParameters.variables = newVariables; 11 | updateURL(); 12 | } 13 | 14 | function onEditOperationName(newOperationName) { 15 | initParameters.operationName = newOperationName; 16 | updateURL(); 17 | } 18 | 19 | function updateURL() { 20 | var newSearch = '?' + Object.keys(initParameters).filter(function (key) { 21 | return Boolean(initParameters[key]); 22 | }).map(function (key) { 23 | return encodeURIComponent(key) + '=' + encodeURIComponent(initParameters[key]); 24 | }).join('&'); 25 | history.replaceState(null, null, newSearch); 26 | } 27 | 28 | function graphQLFetcher(graphQLParams, {headers}) { 29 | return fetch(entrypoint, { 30 | method: 'post', 31 | headers: { 32 | 'Accept': 'application/json', 33 | 'Content-Type': 'application/json', 34 | ...headers 35 | }, 36 | body: JSON.stringify(graphQLParams), 37 | credentials: 'include' 38 | }).then(function (response) { 39 | return response.text(); 40 | }).then(function (responseBody) { 41 | try { 42 | return JSON.parse(responseBody); 43 | } catch (error) { 44 | return responseBody; 45 | } 46 | }); 47 | } 48 | 49 | window.onload = function() { 50 | var data = JSON.parse(document.getElementById('graphiql-data').innerText); 51 | entrypoint = data.entrypoint; 52 | 53 | var search = window.location.search; 54 | search.substr(1).split('&').forEach(function (entry) { 55 | var eq = entry.indexOf('='); 56 | if (eq >= 0) { 57 | initParameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent(entry.slice(eq + 1)); 58 | } 59 | }); 60 | 61 | if (initParameters.variables) { 62 | try { 63 | initParameters.variables = JSON.stringify(JSON.parse(initParameters.variables), null, 2); 64 | } catch (e) { 65 | // Do nothing, we want to display the invalid JSON as a string, rather than present an error. 66 | } 67 | } 68 | 69 | ReactDOM.render( 70 | React.createElement(GraphiQL, { 71 | fetcher: graphQLFetcher, 72 | query: initParameters.query, 73 | variables: initParameters.variables, 74 | operationName: initParameters.operationName, 75 | onEditQuery: onEditQuery, 76 | onEditVariables: onEditVariables, 77 | onEditOperationName: onEditOperationName 78 | }), 79 | document.getElementById('graphiql') 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /public/init-graphql-playground.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', function(event) { 2 | var loadingWrapper = document.getElementById('loading-wrapper'); 3 | loadingWrapper.classList.add('fadeOut'); 4 | 5 | var root = document.getElementById('graphql-playground'); 6 | root.classList.add('playgroundIn'); 7 | 8 | var data = JSON.parse(document.getElementById('graphql-playground-data').innerText); 9 | GraphQLPlayground.init(root, { 10 | 'endpoint': data.entrypoint 11 | }) 12 | }); 13 | -------------------------------------------------------------------------------- /public/init-redoc-ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | window.onload = () => { 4 | const data = JSON.parse(document.getElementById('swagger-data').innerText); 5 | 6 | Redoc.init(data.spec, {}, document.getElementById('swagger-ui')); 7 | }; 8 | -------------------------------------------------------------------------------- /public/swagger-ui/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger UI: OAuth2 Redirect 5 | 6 | 7 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /public/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/laravel/d48c291625f703cc17e7135ded742a1f864f643d/public/web.png -------------------------------------------------------------------------------- /public/webby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/laravel/d48c291625f703cc17e7135ded742a1f864f643d/public/webby.png -------------------------------------------------------------------------------- /resources/views/graphiql.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ config('api-platform.title') }} - API Platform 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
Loading...
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | use ApiPlatform\JsonLd\Action\ContextAction; 15 | use ApiPlatform\Laravel\ApiPlatformMiddleware; 16 | use ApiPlatform\Laravel\Controller\ApiPlatformController; 17 | use ApiPlatform\Laravel\Controller\DocumentationController; 18 | use ApiPlatform\Laravel\Controller\EntrypointController; 19 | use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController; 20 | use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController; 21 | use ApiPlatform\Metadata\Exception\NotExposedHttpException; 22 | use ApiPlatform\Metadata\HttpOperation; 23 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 24 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 25 | use Illuminate\Support\Facades\Route; 26 | 27 | $globalMiddlewares = config()->get('api-platform.routes.middleware', []); 28 | $domain = config()->get('api-platform.routes.domain', ''); 29 | 30 | Route::domain($domain)->middleware($globalMiddlewares)->group(function (): void { 31 | $resourceNameCollectionFactory = app()->make(ResourceNameCollectionFactoryInterface::class); 32 | $resourceMetadataFactory = app()->make(ResourceMetadataCollectionFactoryInterface::class); 33 | 34 | foreach ($resourceNameCollectionFactory->create() as $resourceClass) { 35 | foreach ($resourceMetadataFactory->create($resourceClass) as $resourceMetadata) { 36 | foreach ($resourceMetadata->getOperations() as $operation) { 37 | if ($operation->getRouteName()) { 38 | continue; 39 | } 40 | 41 | $uriTemplate = str_replace('{._format}', '{_format?}', $operation->getUriTemplate()); 42 | 43 | /* @var HttpOperation $operation */ 44 | $route = Route::addRoute($operation->getMethod(), $uriTemplate, ['uses' => ApiPlatformController::class, 'prefix' => $operation->getRoutePrefix() ?? '']) 45 | ->where('_format', '^\.[a-zA-Z]+') 46 | ->name($operation->getName()) 47 | ->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]); 48 | 49 | $route->middleware(ApiPlatformMiddleware::class.':'.$operation->getName()); 50 | 51 | if ($operation->getMiddleware()) { 52 | $route->middleware($operation->getMiddleware()); 53 | } 54 | } 55 | } 56 | } 57 | 58 | $prefix = config()->get('api-platform.defaults.route_prefix', ''); 59 | 60 | Route::group(['prefix' => $prefix], function (): void { 61 | Route::group(['middleware' => ApiPlatformMiddleware::class], function (): void { 62 | Route::get('/contexts/{shortName?}{_format?}', ContextAction::class) 63 | ->name('api_jsonld_context'); 64 | 65 | Route::get('/validation_errors/{id}', fn () => throw new NotExposedHttpException('Not exposed.')) 66 | ->name('api_validation_errors') 67 | ->middleware(ApiPlatformMiddleware::class); 68 | 69 | Route::get('/docs{_format?}', DocumentationController::class) 70 | ->name('api_doc'); 71 | 72 | Route::get('/.well-known/genid/{id}', fn () => throw new NotExposedHttpException('This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.')) 73 | ->name('api_genid'); 74 | 75 | Route::get('/{index?}{_format?}', EntrypointController::class) 76 | ->where('index', 'index') 77 | ->name('api_entrypoint'); 78 | }); 79 | 80 | if (config()->get('api-platform.graphql.enabled')) { 81 | Route::group([ 82 | 'middleware' => config()->get('api-platform.graphql.middleware', []), 83 | ], function (): void { 84 | Route::addRoute(['POST', 'GET'], '/graphql', GraphQlEntrypointController::class) 85 | ->name('api_graphql'); 86 | }); 87 | 88 | if (config()->get('api-platform.graphiql.enabled', true)) { 89 | Route::group([ 90 | 'middleware' => config()->get('api-platform.graphiql.middleware', []), 91 | 'domain' => config()->get('api-platform.graphiql.domain', ''), 92 | ], function (): void { 93 | Route::get('/graphiql', GraphiQlController::class) 94 | ->name('api_graphiql'); 95 | }); 96 | } 97 | } 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Laravel\Tinker\TinkerServiceProvider 3 | - ApiPlatform\Laravel\ApiPlatformProvider 4 | - ApiPlatform\Laravel\ApiPlatformDeferredProvider 5 | - Laravel\Sanctum\SanctumServiceProvider 6 | - Workbench\App\Providers\WorkbenchServiceProvider 7 | 8 | migrations: 9 | - vendor/laravel/sanctum/database/migrations 10 | - workbench/database/migrations 11 | 12 | seeders: 13 | - Workbench\Database\Seeders\DatabaseSeeder 14 | 15 | workbench: 16 | start: /api/docs 17 | welcome: false 18 | install: false 19 | discovers: 20 | web: true 21 | api: false 22 | commands: false 23 | views: false 24 | build: 25 | - asset-publish 26 | - create-sqlite-db 27 | - migrate:refresh 28 | assets: [ ] 29 | sync: 30 | - from: ./workbench/app/Models/ 31 | to: app/Models 32 | - from: ./workbench/app/ApiResource/ 33 | to: app/ApiResource 34 | - from: ./workbench/app/State/ 35 | to: app/State 36 | - from: ./workbench/app/Modules/ 37 | to: app/Modules 38 | - from: ./workbench/app/Services/ 39 | to: app/Services 40 | --------------------------------------------------------------------------------