diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d2dba5096..720db7a08ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v4.0.10 + +### Bug fixes + +* [774590069](https://github.com/api-platform/core/commit/77459006912d9162fdae9eadd496a62858ac990e) fix(laravel): add contact & license options to fix swagger UI issues (#6804) +* [7ff9790c8](https://github.com/api-platform/core/commit/7ff9790c8e326de8152c653974fd523770dc6501) fix(laravel): allow boolean cast (#6808) +* [f19dd9446](https://github.com/api-platform/core/commit/f19dd9446f5722d243e82b1e7bc34ebdab95719d) fix(laravel): graphQl type locator indexes (#6815) + +Also contains [v3.4.7 changes](#v347). + +### Features + ## v4.0.9 ### Bug fixes @@ -199,6 +211,12 @@ Notes: * [0d5f35683](https://github.com/api-platform/core/commit/0d5f356839eb6aa9f536044abe4affa736553e76) feat(laravel): laravel component (#5882) +## v3.4.7 + +### Bug fixes + +* [2d25e79e0](https://github.com/api-platform/core/commit/2d25e79e0e04ce549fb67ecc2017798a8deb7458) fix(symfony): unset item_uri_template when serializing an error (#6816) + ## v3.4.6 ### Bug fixes diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 04534fee036..5d9e6906f5b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -744,6 +744,11 @@ public function register(): void oAuthRefreshUrl: $config->get('api-platform.swagger_ui.oauth.refreshUrl', null), oAuthScopes: $config->get('api-platform.swagger_ui.oauth.scopes', []), apiKeys: $config->get('api-platform.swagger_ui.apiKeys', []), + contactName: $config->get('api-platform.swagger_ui.contact.name', ''), + contactUrl: $config->get('api-platform.swagger_ui.contact.url', ''), + contactEmail: $config->get('api-platform.swagger_ui.contact.email', ''), + licenseName: $config->get('api-platform.swagger_ui.license.name', ''), + licenseUrl: $config->get('api-platform.swagger_ui.license.url', ''), ); }); @@ -1130,14 +1135,18 @@ private function registerGraphQl(Application $app): void $app->singleton('api_platform.graphql.type_locator', function (Application $app) { $tagged = iterator_to_array($app->tagged('api_platform.graphql.type')); + $services = []; + foreach ($tagged as $service) { + $services[$service->name] = $service; + } - return new ServiceLocator($tagged); + return new ServiceLocator($services); }); $app->singleton(TypesFactoryInterface::class, function (Application $app) { $tagged = iterator_to_array($app->tagged('api_platform.graphql.type')); - return new TypesFactory($app->make('api_platform.graphql.type_locator'), array_keys($tagged)); + return new TypesFactory($app->make('api_platform.graphql.type_locator'), array_column($tagged, 'name')); }); $app->singleton(TypesContainerInterface::class, function () { return new TypesContainer(); diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index c88c96585e2..6385a83568c 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -77,6 +77,7 @@ public function create(string $resourceClass, string $property, array $options = $type = match ($builtinType) { 'integer' => new Type(Type::BUILTIN_TYPE_INT, $p['nullable']), 'double', 'real' => new Type(Type::BUILTIN_TYPE_FLOAT, $p['nullable']), + 'boolean', 'bool' => new Type(Type::BUILTIN_TYPE_BOOL, $p['nullable']), 'datetime', 'date', 'timestamp' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTime::class), 'immutable_datetime', 'immutable_date' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTimeImmutable::class), 'collection', 'encrypted:collection' => new Type(Type::BUILTIN_TYPE_ITERABLE, $p['nullable'], Collection::class, true), diff --git a/src/Laravel/Tests/GraphQlTest.php b/src/Laravel/Tests/GraphQlTest.php index 2c3889b84e1..c8044ec12fd 100644 --- a/src/Laravel/Tests/GraphQlTest.php +++ b/src/Laravel/Tests/GraphQlTest.php @@ -66,4 +66,38 @@ public function testGetBooksWithPaginationAndOrder(): void $this->assertCount(3, $data['data']['books']['edges']); $this->assertArrayNotHasKey('errors', $data); } + + public function testCreateBook(): void + { + /** @var \Workbench\App\Models\Author $author */ + $author = AuthorFactory::new()->create(); + $response = $this->postJson('/api/graphql', [ + 'query' => ' + mutation createBook($book: createBookInput!){ + createBook(input: $book){ + book{ + name + isAvailable + } + } + } + ', + 'variables' => [ + 'book' => [ + 'name' => fake()->name(), + 'author' => 'api/authors/'.$author->id, + 'isbn' => fake()->isbn13(), + 'isAvailable' => 1 === random_int(0, 1), + ], + ], + ], ['accept' => ['application/json']]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayNotHasKey('errors', $data); + $this->assertArrayHasKey('data', $data); + $this->assertArrayHasKey('createBook', $data['data']); + $this->assertArrayHasKey('book', $data['data']['createBook']); + $this->assertArrayHasKey('isAvailable', $data['data']['createBook']['book']); + $this->assertIsBool($data['data']['createBook']['book']['isAvailable']); + } } diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index bd1db3114ad..1531d0ed6d4 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -88,7 +88,16 @@ // 'refreshUrl' => '', // 'scopes' => ['scope1' => 'Description scope 1'], // 'pkce' => true - //] + //], + //'license' => [ + // 'name' => 'Apache 2.0', + // 'url' => 'https://www.apache.org/licenses/LICENSE-2.0.html', + //], + //'contact' => [ + // 'name' => 'API Support', + // 'url' => 'https://www.example.com/support', + // 'email' => 'support@example.com', + //], ], 'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH, diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index 33bcf093424..421d21bd7ff 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\Patch; @@ -55,6 +56,7 @@ new QueryParameter(key: 'order[:property]', filter: OrderFilter::class), ], ), + new Mutation(name: 'create'), ] )] #[QueryParameter(key: 'isbn', filter: PartialSearchFilter::class, constraints: 'min:2')] @@ -74,8 +76,11 @@ class Book extends Model use HasFactory; use HasUlids; - protected $visible = ['name', 'author', 'isbn', 'publication_date']; - protected $fillable = ['name']; + protected $visible = ['name', 'author', 'isbn', 'publication_date', 'is_available']; + protected $fillable = ['name', 'is_available']; + protected $casts = [ + 'is_available' => 'boolean', + ]; public function author(): BelongsTo { diff --git a/src/Laravel/workbench/database/factories/BookFactory.php b/src/Laravel/workbench/database/factories/BookFactory.php index 65af45ad5ae..d94b3f281c9 100644 --- a/src/Laravel/workbench/database/factories/BookFactory.php +++ b/src/Laravel/workbench/database/factories/BookFactory.php @@ -37,6 +37,7 @@ public function definition(): array 'author_id' => AuthorFactory::new(), 'isbn' => fake()->isbn13(), 'publication_date' => fake()->optional()->date(), + 'is_available' => 1 === random_int(0, 1), 'internal_note' => fake()->text(), ]; } diff --git a/src/Laravel/workbench/database/migrations/2023_07_15_231244_create_book_table.php b/src/Laravel/workbench/database/migrations/2023_07_15_231244_create_book_table.php index 398e576c3f0..0137554453c 100644 --- a/src/Laravel/workbench/database/migrations/2023_07_15_231244_create_book_table.php +++ b/src/Laravel/workbench/database/migrations/2023_07_15_231244_create_book_table.php @@ -32,6 +32,7 @@ public function up(): void $table->string('name'); $table->string('isbn'); $table->date('publication_date')->nullable(); + $table->boolean('is_available')->default(true); $table->text('internal_note')->nullable(); $table->integer('author_id')->unsigned(); $table->foreign('author_id')->references('id')->on('authors'); diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index d46dda2ced4..fbdc61648ae 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -106,7 +106,7 @@ public function __construct( * 'jsonapi' => ['application/vnd.api+json'], * 'json' => ['application/json'], * 'xml' => ['application/xml', 'text/xml'], - * 'yaml' => ['application/x-yaml'], + * 'yaml' => ['application/yaml'], * 'csv' => ['text/csv'], * 'html' => ['text/html'], * 'myformat' =>['application/vnd.myformat'], diff --git a/src/Symfony/EventListener/ErrorListener.php b/src/Symfony/EventListener/ErrorListener.php index 21dca6001a5..6659cb63cd0 100644 --- a/src/Symfony/EventListener/ErrorListener.php +++ b/src/Symfony/EventListener/ErrorListener.php @@ -99,6 +99,10 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re $normalizationContext += ['api_error_resource' => true]; } + if (isset($normalizationContext['item_uri_template'])) { + unset($normalizationContext['item_uri_template']); + } + if (!isset($normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES])) { $normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES] = ['trace', 'file', 'line', 'code', 'message', 'traceAsString']; } diff --git a/src/Symfony/Routing/Router.php b/src/Symfony/Routing/Router.php index 0b0a76d8f24..716fb8e79a9 100644 --- a/src/Symfony/Routing/Router.php +++ b/src/Symfony/Routing/Router.php @@ -14,9 +14,7 @@ namespace ApiPlatform\Symfony\Routing; use ApiPlatform\Metadata\UrlGeneratorInterface; -use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; @@ -75,12 +73,7 @@ public function match(string $pathInfo): array } $request = Request::create($pathInfo, Request::METHOD_GET, [], [], [], ['HTTP_HOST' => $baseContext->getHost()]); - try { - $context = (new RequestContext())->fromRequest($request); - } catch (RequestExceptionInterface) { - throw new ResourceNotFoundException('Invalid request context.'); - } - + $context = (new RequestContext())->fromRequest($request); $context->setPathInfo($pathInfo); $context->setScheme($baseContext->getScheme()); $context->setHost($baseContext->getHost()); diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6718/Organization.php b/tests/Fixtures/TestBundle/ApiResource/Issue6718/Organization.php new file mode 100644 index 00000000000..aec2f56e71b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6718/Organization.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6718; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'OrganisationIssue6718', + extraProperties: ['rfc_7807_compliant_errors' => true], + operations: [ + new Get( + uriTemplate: '/6718_organisations/{id}', + provider: [self::class, 'itemProvider'], + ), + new Get( + uriTemplate: '/6718_users/{userId}/organisation', + uriVariables: [ + 'userId', + ], + normalizationContext: [ + 'item_uri_template' => '/6718_organisations/{id}', + 'hydra_prefix' => false, + ], + provider: [self::class, 'userOrganizationItemProvider'] + ), + ], +)] +class Organization +{ + public function __construct(public readonly string $id) + { + } + + public static function itemProvider(Operation $operation, array $uriVariables = []): ?self + { + return new self($uriVariables['id']); + } + + public static function userOrganizationItemProvider(): ?self + { + return null; + } +} diff --git a/tests/Functional/ItemUriTemplateTest.php b/tests/Functional/ItemUriTemplateTest.php new file mode 100644 index 00000000000..f1f1812445b --- /dev/null +++ b/tests/Functional/ItemUriTemplateTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +class ItemUriTemplateTest extends ApiTestCase +{ + public function testIssue6718(): void + { + self::createClient()->request('GET', '/6718_users/1/organisation', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(404); + $this->assertJsonContains(['description' => 'Not Found']); + } +} diff --git a/tests/Symfony/Routing/RouterTest.php b/tests/Symfony/Routing/RouterTest.php index e24b1bbb38c..72cf66484e7 100644 --- a/tests/Symfony/Routing/RouterTest.php +++ b/tests/Symfony/Routing/RouterTest.php @@ -18,7 +18,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; @@ -96,19 +95,6 @@ public function testMatch(): void $this->assertEquals(['bar'], $router->match('/app_dev.php/foo')); } - public function testMatchWithInvalidContext(): void - { - $this->expectException(RoutingExceptionInterface::class); - $this->expectExceptionMessage('Invalid request context.'); - $context = new RequestContext('/app_dev.php', 'GET', 'localhost', 'https'); - - $mockedRouter = $this->prophesize(RouterInterface::class); - $mockedRouter->getContext()->willReturn($context)->shouldBeCalled(); - - $router = new Router($mockedRouter->reveal()); - $router->match('28-01-2018 10:10'); - } - public function testMatchDuplicatedBaseUrl(): void { $context = new RequestContext('/app', 'GET', 'localhost', 'https');