From 1166405b4ab2ddc6a10e8368183ab17091928b65 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 4 Jul 2023 16:24:15 +0200 Subject: [PATCH 01/51] docs: add ADR --- docs/adr/0000-book-fields.md | 34 +++++++++++++++ docs/adr/0001-save-book-data-from-bnf-api.md | 29 +++++++++++++ ...book-reviews-property-as-collection-iri.md | 42 +++++++++++++++++++ docs/adr/0003-live-refresh-on-book-updates.md | 39 +++++++++++++++++ docs/adr/0004-get-user-data.md | 39 +++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 docs/adr/0000-book-fields.md create mode 100644 docs/adr/0001-save-book-data-from-bnf-api.md create mode 100644 docs/adr/0002-book-reviews-property-as-collection-iri.md create mode 100644 docs/adr/0003-live-refresh-on-book-updates.md create mode 100644 docs/adr/0004-get-user-data.md diff --git a/docs/adr/0000-book-fields.md b/docs/adr/0000-book-fields.md new file mode 100644 index 000000000..f1dde70f8 --- /dev/null +++ b/docs/adr/0000-book-fields.md @@ -0,0 +1,34 @@ +# Book Fields + +* Status: accepted +* Deciders: @gregoirehebert, @vincentchalamon + +## Context and Problem Statement + +Considering the Book resource with a `book` property, exposing an IRI of a book from the BNF API. With this +architecture, the client could request this IRI to retrieve the book data. + +But how can the client filters the books by title and author from our API if we don't handle those properties? + +## Considered Options + +A first option would be to let the client request on the BNF API, retrieve a collection of books IRI, then use it to +filter the books collection on our API (using `book` query filter). This approach cannot work properly because the BNF +API will return IRIs which won't be registered in our API. + +Another option would be to enable custom filters on our API (`title` and `author`). Then, the API will call the BNF +API to retrieve a collection of books IRI, and use it in a Doctrine query to filter the API Book objects by the `book` +property. It exposes the API to a performance issue if the BNF API returns a huge amount of IRIs. Restricting this +collection (e.g.: limiting the BNF request to 100 results) may ignore some books arriving later. + +To fix this last option issues, another option is to list all API Book IRIs, then filter the BNF API by title, author +and this collection to retrieve only IRIs that match those filters. But the performance issue still remains if our API +manages a huge collection of books. + +Finally, the last considered option would be to duplicate the title and author properties on our API for filtering +usage. + +## Decision Outcome + +The last considered option has been selected as the best compromise in such situation. The API will call the BNF API on +Book creation, retrieve the `title` and `author` properties and save them for local filtering usage. diff --git a/docs/adr/0001-save-book-data-from-bnf-api.md b/docs/adr/0001-save-book-data-from-bnf-api.md new file mode 100644 index 000000000..8ff11dc04 --- /dev/null +++ b/docs/adr/0001-save-book-data-from-bnf-api.md @@ -0,0 +1,29 @@ +# Save Book Data from BNF API + +* Status: accepted +* Deciders: @gregoirehebert, @vincentchalamon + +## Context and Problem Statement + +Some Book data come from the BNF API (cf. [Book Fields](0000-book-fields.md)). How to retrieve and aggregate them +before saving a Book object in the database? + +## Considered Options + +A first option would be to use a custom entity listener with Doctrine. This approach would let us complete the Book +entity right before save by calling the BNF API and retrieving the properties. But using those lifecycle callbacks are +a bad practice and "_are supposed to be the ORM-specific serialize and unserialize_" +(cf. ["Doctrine 2 ORM Best Practices" by Ocramius](https://ocramius.github.io/doctrine-best-practices/)). + +Another option would be to use a custom [State Processor](https://api-platform.com/docs/core/state-processors/) to +retrieve the data from the BNF API, then update and save the Book object. + +## Decision Outcome + +The last solution is preferred as it's the recommended way by API Platform to handle a custom save on a resource. + +## Links + +* [Book Fields ADR](0000-book-fields.md) +* ["Doctrine 2 ORM Best Practices" by Ocramius](https://ocramius.github.io/doctrine-best-practices/) +* [API Platform State Processor](https://api-platform.com/docs/core/state-processors/) diff --git a/docs/adr/0002-book-reviews-property-as-collection-iri.md b/docs/adr/0002-book-reviews-property-as-collection-iri.md new file mode 100644 index 000000000..b9280216e --- /dev/null +++ b/docs/adr/0002-book-reviews-property-as-collection-iri.md @@ -0,0 +1,42 @@ +# Book.reviews Property as Collection IRI + +* Status: accepted +* Deciders: @gregoirehebert, @vincentchalamon + +## Context and Problem Statement + +A Book may have a lot of reviews, like thousands. Exposing the reviews on a Book may cause an over-fetching issue. + +The client may have to show the reviews, or may not. For instance, we want the front client to show the reviews on a +Book page, but on the admin client it's not necessary. + +How can we expose a Book reviews without provoking any under/over-fetching, and without requesting the reviews on the +database when it's not necessary? + +## Considered Options + +Thanks to [Vulcain](https://vulcain.rocks/), it is possible to preload some data and push them to the client. But how +the `Book.reviews` data should be exposed? + +The first considered option would be to only expose the IRIs of each review from a Book. But it doesn't solve the +over-fetching issue if the Book has a lot of reviews. Also, this list wouldn't be paginated nor filtered. These would +be huge limitations over the reviews collection. + +Another considered option is to expose the IRI of the Book reviews (e.g.: `/books/{id}/reviews`), and let +[Vulcain](https://vulcain.rocks/) request it when necessary. This IRI would expose a paginated and filtered list of +reviews related to this Book. It would also be possible to manage the authorization differently than Review main +endpoint, for admin usage for instance. + +## Decision Outcome + +The last option would be the best solution as it respects the +[Hydra Spec](https://www.hydra-cg.com/spec/latest/core/#example-5-using-json-ld-s-type-coercion-feature-to-create-idiomatic-representations) +and prevent any over-fetching. + +The Book JSON-LD response would return an IRI for `reviews` property, which can be parsed with +[Vulcain](https://vulcain.rocks/) to preload them, and keep any pagination and filtering features. + +## Links + +* [Vulcain](https://vulcain.rocks/) +* [Hydra Spec - Using JSON-LD's type-coercion feature to create idiomatic representations](https://www.hydra-cg.com/spec/latest/core/#example-5-using-json-ld-s-type-coercion-feature-to-create-idiomatic-representations) diff --git a/docs/adr/0003-live-refresh-on-book-updates.md b/docs/adr/0003-live-refresh-on-book-updates.md new file mode 100644 index 000000000..707d2db3a --- /dev/null +++ b/docs/adr/0003-live-refresh-on-book-updates.md @@ -0,0 +1,39 @@ +# Live Refresh on Book Updates + +* Status: accepted +* Deciders: @gregoirehebert, @vincentchalamon + +## Context and Problem Statement + +When an admin creates, updates or removes a book, the users must instantly see this modification on the client. + +## Considered Options + +PostgreSQL implements a [Notify](https://www.postgresql.org/docs/current/sql-notify.html) command which sends a +notification event together with an optional "payload" string to each client application that has previously +executed a `LISTEN **_channel_**` for the specified channel name in the current database. This option implies a custom +PHP script on the API to handle the connection between the client and the database, which requires tests, performances +checks, security, etc. It also implies a PostgreSQL procedure which locks the API to this database system. + +[WebSockets API](https://developer.mozilla.org/fr/docs/Web/API/WebSockets_API) is an advanced technology to open a +two-way interactive communication session between the user's browser and the server. [Caddy](https://caddyserver.com/) +is able to handle it, as many other servers and solutions. This would be a valid working solution. + +[Meteor.js](https://www.meteor.com/) is an open source platform for seamlessly building and deploying Web, Mobile, and +Desktop applications in Javascript or TypeScript. Installed as an API Gateway, it would be a valid working solution too. + +[Mercure](https://mercure.rocks/) is an open solution for real-time communications designed to be fast, reliable and +battery-efficient. As previous solutions, it would be a valid working one. + +## Decision Outcome + +Among all those good solutions found, [Mercure](https://mercure.rocks/) would be the most appropriate one thanks to its +integration in API Platform and Caddy. No extra server would be necessary, and it's easily usable with API Platform. + +## Links + +* [PostgreSQL Notify](https://www.postgresql.org/docs/current/sql-notify.html) +* [WebSockets API](https://developer.mozilla.org/fr/docs/Web/API/WebSockets_API) +* [Caddy](https://caddyserver.com/) +* [Meteor.js](https://www.meteor.com/) +* [Mercure](https://mercure.rocks/) diff --git a/docs/adr/0004-get-user-data.md b/docs/adr/0004-get-user-data.md new file mode 100644 index 000000000..7a2a2584d --- /dev/null +++ b/docs/adr/0004-get-user-data.md @@ -0,0 +1,39 @@ +# Get User Data + +* Status: accepted +* Deciders: @gregoirehebert, @vincentchalamon + +## Context and Problem Statement + +When a user downloads a book, a Download object is created. An admin can list all those objects to check all the books +that have been downloaded. For each of them, an admin must see the data of the user (`firstName` and `lastName`). + +Users come from an OIDC server. + +## Considered Options + +A Download object should save the IRI of the user from the OIDC server +(e.g.: `https://demo.api-platform.com/oidc/users/{id}`). Then, the admin client could authenticate on the OIDC API, and +request this IRI to retrieve the user data. This project currently uses [Keycloak](https://keycloak.org/) OIDC server, +which only enables the API for administrators of the OIDC server for security reasons. The admin client would not be +able to request it, as the admin client user is not the same as an administrator of the OIDC server. + +Another option would be to create exactly the same users on [Keycloak](https://keycloak.org/) and the API. But what if a +new user is added on [Keycloak](https://keycloak.org/)? It won't be automatically synchronized on the API, some data +might be different. + +Last solution would be on the API side. The authentication process is already done by [Keycloak](https://keycloak.org/). +A check is done on the API side thanks to Symfony. If the user is valid and fully authenticated according to this +authenticator and [Keycloak](https://keycloak.org/), we could try to find the user in the database or create it, and +update it if necessary. + +## Decision Outcome + +The last solution would be the best compromise. Thanks to it, the users on the API will always be synchronized with +[Keycloak](https://keycloak.org/), and we're able to expose an API over the users restricted to admins +(e.g.: `https://demo.api-platform.com/users/{id}`). + +## Links + +* [Keycloak](https://keycloak.org/) +* [Symfony AccessToken Authenticator](https://symfony.com/doc/current/security/access_token.html) From 2bd0884a536ed2506d2f27800bd7761c914609d8 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 30 Jun 2023 11:49:10 +0200 Subject: [PATCH 02/51] chore: update API dependencies and recipes --- api/.env | 2 +- api/composer.lock | 378 +++++++++++++++--------------- api/config/packages/doctrine.yaml | 2 + api/phpunit.xml.dist | 10 +- api/symfony.lock | 53 ++--- api/templates/base.html.twig | 3 - 6 files changed, 221 insertions(+), 227 deletions(-) diff --git a/api/.env b/api/.env index 022374a20..e6be5cb84 100644 --- a/api/.env +++ b/api/.env @@ -53,7 +53,7 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" ###> symfony/messenger ### # Choose one of the transports below -MESSENGER_TRANSPORT_DSN=doctrine://default # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###< symfony/messenger ### diff --git a/api/composer.lock b/api/composer.lock index 6758803e0..953fa6ddf 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -503,16 +503,16 @@ }, { "name": "doctrine/dbal", - "version": "3.6.2", + "version": "3.6.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "b4bd1cfbd2b916951696d82e57d054394d84864c" + "reference": "19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/b4bd1cfbd2b916951696d82e57d054394d84864c", - "reference": "b4bd1cfbd2b916951696d82e57d054394d84864c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f", + "reference": "19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f", "shasum": "" }, "require": { @@ -525,12 +525,12 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "11.1.0", + "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2022.3", - "phpstan/phpstan": "1.10.9", + "phpstan/phpstan": "1.10.14", "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "9.6.6", + "phpunit/phpunit": "9.6.7", "psalm/plugin-phpunit": "0.18.4", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^5.4|^6.0", @@ -595,7 +595,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.6.2" + "source": "https://github.com/doctrine/dbal/tree/3.6.4" }, "funding": [ { @@ -611,7 +611,7 @@ "type": "tidelift" } ], - "time": "2023-04-14T07:25:38+00:00" + "time": "2023-06-15T07:40:12+00:00" }, { "name": "doctrine/deprecations", @@ -658,16 +658,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.9.1", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "7539b3c8bd620f7df6c2c6d510204bd2ce0064e3" + "reference": "f9d59c90b6f525dfc2a2064a695cb56e0ab40311" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/7539b3c8bd620f7df6c2c6d510204bd2ce0064e3", - "reference": "7539b3c8bd620f7df6c2c6d510204bd2ce0064e3", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/f9d59c90b6f525dfc2a2064a695cb56e0ab40311", + "reference": "f9d59c90b6f525dfc2a2064a695cb56e0ab40311", "shasum": "" }, "require": { @@ -754,7 +754,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.9.1" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.10.1" }, "funding": [ { @@ -770,20 +770,20 @@ "type": "tidelift" } ], - "time": "2023-04-14T05:39:34+00:00" + "time": "2023-06-28T07:47:41+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.2.3", + "version": "3.2.4", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "05490c74141ecd285ac7d38cef1047ed0abadc47" + "reference": "94e6b0fe1a50901d52f59dbb9b4b0737718b2c1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/05490c74141ecd285ac7d38cef1047ed0abadc47", - "reference": "05490c74141ecd285ac7d38cef1047ed0abadc47", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/94e6b0fe1a50901d52f59dbb9b4b0737718b2c1e", + "reference": "94e6b0fe1a50901d52f59dbb9b4b0737718b2c1e", "shasum": "" }, "require": { @@ -839,7 +839,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.2.3" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.2.4" }, "funding": [ { @@ -855,7 +855,7 @@ "type": "tidelift" } ], - "time": "2023-05-02T13:24:05+00:00" + "time": "2023-06-02T08:19:26+00:00" }, { "name": "doctrine/event-manager", @@ -1291,16 +1291,16 @@ }, { "name": "doctrine/orm", - "version": "2.15.1", + "version": "2.15.3", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc" + "reference": "4c3bd208018c26498e5f682aaad45fa00ea307d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc", - "reference": "9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc", + "url": "https://api.github.com/repos/doctrine/orm/zipball/4c3bd208018c26498e5f682aaad45fa00ea307d5", + "reference": "4c3bd208018c26498e5f682aaad45fa00ea307d5", "shasum": "" }, "require": { @@ -1329,14 +1329,14 @@ "doctrine/annotations": "^1.13 || ^2", "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", - "phpstan/phpstan": "~1.4.10 || 1.10.14", + "phpstan/phpstan": "~1.4.10 || 1.10.18", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.11.0" + "vimeo/psalm": "4.30.0 || 5.12.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -1386,9 +1386,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.15.1" + "source": "https://github.com/doctrine/orm/tree/2.15.3" }, - "time": "2023-05-07T18:56:25+00:00" + "time": "2023-06-22T12:36:06+00:00" }, { "name": "doctrine/persistence", @@ -1617,16 +1617,16 @@ }, { "name": "monolog/monolog", - "version": "3.3.1", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "9b5daeaffce5b926cac47923798bba91059e60e2" + "reference": "e2392369686d420ca32df3803de28b5d6f76867d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/9b5daeaffce5b926cac47923798bba91059e60e2", - "reference": "9b5daeaffce5b926cac47923798bba91059e60e2", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/e2392369686d420ca32df3803de28b5d6f76867d", + "reference": "e2392369686d420ca32df3803de28b5d6f76867d", "shasum": "" }, "require": { @@ -1641,7 +1641,7 @@ "doctrine/couchdb": "~1.0@dev", "elasticsearch/elasticsearch": "^7 || ^8", "ext-json": "*", - "graylog2/gelf-php": "^1.4.2 || ^2@dev", + "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", "mongodb/mongodb": "^1.8", @@ -1649,7 +1649,7 @@ "phpstan/phpstan": "^1.9", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^9.5.26", + "phpunit/phpunit": "^10.1", "predis/predis": "^1.1 || ^2", "ruflin/elastica": "^7", "symfony/mailer": "^5.4 || ^6", @@ -1702,7 +1702,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.3.1" + "source": "https://github.com/Seldaek/monolog/tree/3.4.0" }, "funding": [ { @@ -1714,7 +1714,7 @@ "type": "tidelift" } ], - "time": "2023-02-06T13:46:10+00:00" + "time": "2023-06-21T08:46:11+00:00" }, { "name": "nelmio/cors-bundle", @@ -1847,22 +1847,23 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.21.3", + "version": "1.22.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "b0c366dd2cea79407d635839d25423ba07c55dd6" + "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b0c366dd2cea79407d635839d25423ba07c55dd6", - "reference": "b0c366dd2cea79407d635839d25423ba07c55dd6", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", + "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { + "doctrine/annotations": "^2.0", "nikic/php-parser": "^4.15", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", @@ -1887,9 +1888,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.21.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.22.0" }, - "time": "2023-05-29T19:31:28+00:00" + "time": "2023-06-01T12:35:21+00:00" }, { "name": "psr/cache", @@ -2378,16 +2379,16 @@ }, { "name": "symfony/cache", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "357bf04b1380f71e40b2d6592dbf7f2a948ca6b1" + "reference": "52cff7608ef6e38376ac11bd1fbb0a220107f066" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/357bf04b1380f71e40b2d6592dbf7f2a948ca6b1", - "reference": "357bf04b1380f71e40b2d6592dbf7f2a948ca6b1", + "url": "https://api.github.com/repos/symfony/cache/zipball/52cff7608ef6e38376ac11bd1fbb0a220107f066", + "reference": "52cff7608ef6e38376ac11bd1fbb0a220107f066", "shasum": "" }, "require": { @@ -2454,7 +2455,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.3.0" + "source": "https://github.com/symfony/cache/tree/v6.3.1" }, "funding": [ { @@ -2470,7 +2471,7 @@ "type": "tidelift" } ], - "time": "2023-05-10T09:21:01+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "symfony/cache-contracts", @@ -2550,16 +2551,16 @@ }, { "name": "symfony/clock", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "ccae3a2f1eb48a2515c84b8d456679fe3d79c9ea" + "reference": "2c72817f85cbdd0ae4e49643514a889004934296" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/ccae3a2f1eb48a2515c84b8d456679fe3d79c9ea", - "reference": "ccae3a2f1eb48a2515c84b8d456679fe3d79c9ea", + "url": "https://api.github.com/repos/symfony/clock/zipball/2c72817f85cbdd0ae4e49643514a889004934296", + "reference": "2c72817f85cbdd0ae4e49643514a889004934296", "shasum": "" }, "require": { @@ -2603,7 +2604,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v6.3.0" + "source": "https://github.com/symfony/clock/tree/v6.3.1" }, "funding": [ { @@ -2619,7 +2620,7 @@ "type": "tidelift" } ], - "time": "2023-02-21T10:58:00+00:00" + "time": "2023-06-08T23:46:55+00:00" }, { "name": "symfony/config", @@ -2788,16 +2789,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "ebf5f9c5bb5c21d75ab74995ce5e26c3fbbda44d" + "reference": "7abf242af21f196b65f20ab00ff251fdf3889b8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ebf5f9c5bb5c21d75ab74995ce5e26c3fbbda44d", - "reference": "ebf5f9c5bb5c21d75ab74995ce5e26c3fbbda44d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/7abf242af21f196b65f20ab00ff251fdf3889b8d", + "reference": "7abf242af21f196b65f20ab00ff251fdf3889b8d", "shasum": "" }, "require": { @@ -2849,7 +2850,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.3.0" + "source": "https://github.com/symfony/dependency-injection/tree/v6.3.1" }, "funding": [ { @@ -2865,7 +2866,7 @@ "type": "tidelift" } ], - "time": "2023-05-30T17:12:32+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2936,16 +2937,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "501d0c8dc627d2f6ad6185920d4db74477a98e75" + "reference": "594263c7d2677022a16e4f39d20070463ba03888" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/501d0c8dc627d2f6ad6185920d4db74477a98e75", - "reference": "501d0c8dc627d2f6ad6185920d4db74477a98e75", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/594263c7d2677022a16e4f39d20070463ba03888", + "reference": "594263c7d2677022a16e4f39d20070463ba03888", "shasum": "" }, "require": { @@ -2972,7 +2973,7 @@ "symfony/property-info": "<5.4", "symfony/security-bundle": "<5.4", "symfony/security-core": "<6.0", - "symfony/validator": "<5.4" + "symfony/validator": "<5.4.25|>=6,<6.2.12|>=6.3,<6.3.1" }, "require-dev": { "doctrine/annotations": "^1.13.1|^2", @@ -2997,7 +2998,7 @@ "symfony/stopwatch": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "symfony/uid": "^5.4|^6.0", - "symfony/validator": "^5.4|^6.0", + "symfony/validator": "^5.4.25|~6.2.12|^6.3.1", "symfony/var-dumper": "^5.4|^6.0" }, "type": "symfony-bridge", @@ -3026,7 +3027,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.0" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.1" }, "funding": [ { @@ -3042,20 +3043,20 @@ "type": "tidelift" } ], - "time": "2023-05-25T13:09:35+00:00" + "time": "2023-06-18T20:33:34+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "85d3c2c2e1d0c7c6828c279534b2956a93a0ad6d" + "reference": "f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/85d3c2c2e1d0c7c6828c279534b2956a93a0ad6d", - "reference": "85d3c2c2e1d0c7c6828c279534b2956a93a0ad6d", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb", + "reference": "f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb", "shasum": "" }, "require": { @@ -3098,7 +3099,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v6.3.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/v6.3.1" }, "funding": [ { @@ -3114,7 +3115,7 @@ "type": "tidelift" } ], - "time": "2023-05-15T15:58:35+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "symfony/dotenv", @@ -3678,16 +3679,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "4e082c10ae0c8b80e329024ebc60815fcc4206ff" + "reference": "42b0707efba17ca7c6af7373cb1b1a1b4f24f772" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4e082c10ae0c8b80e329024ebc60815fcc4206ff", - "reference": "4e082c10ae0c8b80e329024ebc60815fcc4206ff", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/42b0707efba17ca7c6af7373cb1b1a1b4f24f772", + "reference": "42b0707efba17ca7c6af7373cb1b1a1b4f24f772", "shasum": "" }, "require": { @@ -3696,7 +3697,7 @@ "php": ">=8.1", "symfony/cache": "^5.4|^6.0", "symfony/config": "^6.1", - "symfony/dependency-injection": "^6.3", + "symfony/dependency-injection": "^6.3.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.1", "symfony/event-dispatcher": "^5.4|^6.0", @@ -3802,7 +3803,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.3.0" + "source": "https://github.com/symfony/framework-bundle/tree/v6.3.1" }, "funding": [ { @@ -3818,20 +3819,20 @@ "type": "tidelift" } ], - "time": "2023-05-30T15:24:33+00:00" + "time": "2023-06-24T09:59:31+00:00" }, { "name": "symfony/http-client", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "b2f892c91e4e02a939edddeb7ef452522d10a424" + "reference": "1c828a06aef2f5eeba42026dfc532d4fc5406123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/b2f892c91e4e02a939edddeb7ef452522d10a424", - "reference": "b2f892c91e4e02a939edddeb7ef452522d10a424", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1c828a06aef2f5eeba42026dfc532d4fc5406123", + "reference": "1c828a06aef2f5eeba42026dfc532d4fc5406123", "shasum": "" }, "require": { @@ -3894,7 +3895,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.3.0" + "source": "https://github.com/symfony/http-client/tree/v6.3.1" }, "funding": [ { @@ -3910,7 +3911,7 @@ "type": "tidelift" } ], - "time": "2023-05-12T08:49:48+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "symfony/http-client-contracts", @@ -3992,16 +3993,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "718a97ed430d34e5c568ea2c44eab708c6efbefb" + "reference": "e0ad0d153e1c20069250986cd9e9dd1ccebb0d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/718a97ed430d34e5c568ea2c44eab708c6efbefb", - "reference": "718a97ed430d34e5c568ea2c44eab708c6efbefb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e0ad0d153e1c20069250986cd9e9dd1ccebb0d66", + "reference": "e0ad0d153e1c20069250986cd9e9dd1ccebb0d66", "shasum": "" }, "require": { @@ -4049,7 +4050,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.3.0" + "source": "https://github.com/symfony/http-foundation/tree/v6.3.1" }, "funding": [ { @@ -4065,20 +4066,20 @@ "type": "tidelift" } ], - "time": "2023-05-19T12:46:45+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "241973f3dd900620b1ca052fe409144f11aea748" + "reference": "161e16fd2e35fb4881a43bc8b383dfd5be4ac374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/241973f3dd900620b1ca052fe409144f11aea748", - "reference": "241973f3dd900620b1ca052fe409144f11aea748", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/161e16fd2e35fb4881a43bc8b383dfd5be4ac374", + "reference": "161e16fd2e35fb4881a43bc8b383dfd5be4ac374", "shasum": "" }, "require": { @@ -4162,7 +4163,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.3.0" + "source": "https://github.com/symfony/http-kernel/tree/v6.3.1" }, "funding": [ { @@ -4178,7 +4179,7 @@ "type": "tidelift" } ], - "time": "2023-05-30T19:03:32+00:00" + "time": "2023-06-26T06:07:32+00:00" }, { "name": "symfony/mercure", @@ -4349,16 +4350,16 @@ }, { "name": "symfony/messenger", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "a1118de0626c2a44ed1947f7c7a3c9118a0265f1" + "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/a1118de0626c2a44ed1947f7c7a3c9118a0265f1", - "reference": "a1118de0626c2a44ed1947f7c7a3c9118a0265f1", + "url": "https://api.github.com/repos/symfony/messenger/zipball/e92ae9997f36e1189ff8251636adc21b8c9a6bea", + "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea", "shasum": "" }, "require": { @@ -4414,7 +4415,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v6.3.0" + "source": "https://github.com/symfony/messenger/tree/v6.3.1" }, "funding": [ { @@ -4430,20 +4431,20 @@ "type": "tidelift" } ], - "time": "2023-05-25T08:59:50+00:00" + "time": "2023-06-21T12:08:28+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "d45a22a6cca0a0a2a60171bdf137d9001ac5531b" + "reference": "04b04b8e465e0fa84940e5609b6796a8b4e51bf1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/d45a22a6cca0a0a2a60171bdf137d9001ac5531b", - "reference": "d45a22a6cca0a0a2a60171bdf137d9001ac5531b", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/04b04b8e465e0fa84940e5609b6796a8b4e51bf1", + "reference": "04b04b8e465e0fa84940e5609b6796a8b4e51bf1", "shasum": "" }, "require": { @@ -4492,7 +4493,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.3.0" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.3.1" }, "funding": [ { @@ -4508,7 +4509,7 @@ "type": "tidelift" } ], - "time": "2023-04-24T14:22:26+00:00" + "time": "2023-06-08T11:13:32+00:00" }, { "name": "symfony/monolog-bundle", @@ -4984,16 +4985,16 @@ }, { "name": "symfony/routing", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "827f59fdc67eecfc4dfff81f9c93bf4d98f0c89b" + "reference": "d37ad1779c38b8eb71996d17dc13030dcb7f9cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/827f59fdc67eecfc4dfff81f9c93bf4d98f0c89b", - "reference": "827f59fdc67eecfc4dfff81f9c93bf4d98f0c89b", + "url": "https://api.github.com/repos/symfony/routing/zipball/d37ad1779c38b8eb71996d17dc13030dcb7f9cf5", + "reference": "d37ad1779c38b8eb71996d17dc13030dcb7f9cf5", "shasum": "" }, "require": { @@ -5046,7 +5047,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.3.0" + "source": "https://github.com/symfony/routing/tree/v6.3.1" }, "funding": [ { @@ -5062,20 +5063,20 @@ "type": "tidelift" } ], - "time": "2023-04-28T15:57:00+00:00" + "time": "2023-06-05T15:30:22+00:00" }, { "name": "symfony/runtime", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "d998ab9da99e2ebca719dee00e8469996deeec53" + "reference": "8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/d998ab9da99e2ebca719dee00e8469996deeec53", - "reference": "d998ab9da99e2ebca719dee00e8469996deeec53", + "url": "https://api.github.com/repos/symfony/runtime/zipball/8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d", + "reference": "8e83b5d8e0ace903e1a91dedfe08a84ed2a54b0d", "shasum": "" }, "require": { @@ -5087,7 +5088,7 @@ }, "require-dev": { "composer/composer": "^1.0.2|^2.0", - "symfony/console": "^5.4|^6.0", + "symfony/console": "^5.4.9|^6.0.9", "symfony/dotenv": "^5.4|^6.0", "symfony/http-foundation": "^5.4|^6.0", "symfony/http-kernel": "^5.4|^6.0" @@ -5125,7 +5126,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v6.3.0" + "source": "https://github.com/symfony/runtime/tree/v6.3.1" }, "funding": [ { @@ -5141,20 +5142,20 @@ "type": "tidelift" } ], - "time": "2023-03-14T15:56:29+00:00" + "time": "2023-06-21T12:08:28+00:00" }, { "name": "symfony/security-bundle", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "4d18f4cdb71e6f6ec1cf2c6b0349642e762b812f" + "reference": "f4fe79d7ebafd406e1a6f646839bfbbed641d8b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/4d18f4cdb71e6f6ec1cf2c6b0349642e762b812f", - "reference": "4d18f4cdb71e6f6ec1cf2c6b0349642e762b812f", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f4fe79d7ebafd406e1a6f646839bfbbed641d8b2", + "reference": "f4fe79d7ebafd406e1a6f646839bfbbed641d8b2", "shasum": "" }, "require": { @@ -5190,6 +5191,7 @@ "symfony/expression-language": "^5.4|^6.0", "symfony/form": "^5.4|^6.0", "symfony/framework-bundle": "^6.3", + "symfony/http-client": "^5.4|^6.0", "symfony/ldap": "^5.4|^6.0", "symfony/process": "^5.4|^6.0", "symfony/rate-limiter": "^5.4|^6.0", @@ -5233,7 +5235,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.3.0" + "source": "https://github.com/symfony/security-bundle/tree/v6.3.1" }, "funding": [ { @@ -5249,7 +5251,7 @@ "type": "tidelift" } ], - "time": "2023-05-30T19:01:06+00:00" + "time": "2023-06-21T12:08:28+00:00" }, { "name": "symfony/security-core", @@ -5405,16 +5407,16 @@ }, { "name": "symfony/security-http", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "7f35dd7df8336fd55bdb0a950e52632c7e5fa43f" + "reference": "36d2bdd09c33f63014dc65f164a77ff099d256c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/7f35dd7df8336fd55bdb0a950e52632c7e5fa43f", - "reference": "7f35dd7df8336fd55bdb0a950e52632c7e5fa43f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/36d2bdd09c33f63014dc65f164a77ff099d256c6", + "reference": "36d2bdd09c33f63014dc65f164a77ff099d256c6", "shasum": "" }, "require": { @@ -5472,7 +5474,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.3.0" + "source": "https://github.com/symfony/security-http/tree/v6.3.1" }, "funding": [ { @@ -5488,20 +5490,20 @@ "type": "tidelift" } ], - "time": "2023-05-30T19:01:06+00:00" + "time": "2023-06-18T15:50:12+00:00" }, { "name": "symfony/serializer", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "990e724240d32c0110a853675f8560bb2bf25dcf" + "reference": "1d238ee3180bc047f8ab713bfb73848d553f4407" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/990e724240d32c0110a853675f8560bb2bf25dcf", - "reference": "990e724240d32c0110a853675f8560bb2bf25dcf", + "url": "https://api.github.com/repos/symfony/serializer/zipball/1d238ee3180bc047f8ab713bfb73848d553f4407", + "reference": "1d238ee3180bc047f8ab713bfb73848d553f4407", "shasum": "" }, "require": { @@ -5565,7 +5567,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.3.0" + "source": "https://github.com/symfony/serializer/tree/v6.3.1" }, "funding": [ { @@ -5581,7 +5583,7 @@ "type": "tidelift" } ], - "time": "2023-05-29T12:49:39+00:00" + "time": "2023-06-21T19:54:33+00:00" }, { "name": "symfony/service-contracts", @@ -6160,16 +6162,16 @@ }, { "name": "symfony/validator", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "b18c05b5f52e0428688191da3a856b64c7a022db" + "reference": "1b71f43c62ee867ab08195ba6039a1bc3e6654dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/b18c05b5f52e0428688191da3a856b64c7a022db", - "reference": "b18c05b5f52e0428688191da3a856b64c7a022db", + "url": "https://api.github.com/repos/symfony/validator/zipball/1b71f43c62ee867ab08195ba6039a1bc3e6654dc", + "reference": "1b71f43c62ee867ab08195ba6039a1bc3e6654dc", "shasum": "" }, "require": { @@ -6236,7 +6238,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.3.0" + "source": "https://github.com/symfony/validator/tree/v6.3.1" }, "funding": [ { @@ -6252,20 +6254,20 @@ "type": "tidelift" } ], - "time": "2023-05-25T13:09:35+00:00" + "time": "2023-06-21T12:08:28+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6acdcd5c122074ee9f7b051e4fb177025c277a0e" + "reference": "c81268d6960ddb47af17391a27d222bd58cf0515" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6acdcd5c122074ee9f7b051e4fb177025c277a0e", - "reference": "6acdcd5c122074ee9f7b051e4fb177025c277a0e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c81268d6960ddb47af17391a27d222bd58cf0515", + "reference": "c81268d6960ddb47af17391a27d222bd58cf0515", "shasum": "" }, "require": { @@ -6318,7 +6320,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.3.0" + "source": "https://github.com/symfony/var-dumper/tree/v6.3.1" }, "funding": [ { @@ -6334,7 +6336,7 @@ "type": "tidelift" } ], - "time": "2023-05-25T13:09:35+00:00" + "time": "2023-06-21T12:08:28+00:00" }, { "name": "symfony/var-exporter", @@ -8015,16 +8017,16 @@ }, { "name": "fakerphp/faker", - "version": "v1.22.0", + "version": "v1.23.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2" + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/f85772abd508bd04e20bb4b1bbe260a68d0066d2", - "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", "shasum": "" }, "require": { @@ -8077,9 +8079,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.22.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.23.0" }, - "time": "2023-05-14T12:31:37+00:00" + "time": "2023-06-12T08:44:38+00:00" }, { "name": "felixfbecker/advanced-json-rpc", @@ -9343,16 +9345,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "2611ec97006953c3b21ac9f3c52a6a252483e637" + "reference": "8aa333f41f05afc7fc285a976b58272fd90fc212" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2611ec97006953c3b21ac9f3c52a6a252483e637", - "reference": "2611ec97006953c3b21ac9f3c52a6a252483e637", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8aa333f41f05afc7fc285a976b58272fd90fc212", + "reference": "8aa333f41f05afc7fc285a976b58272fd90fc212", "shasum": "" }, "require": { @@ -9390,7 +9392,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.3.0" + "source": "https://github.com/symfony/dom-crawler/tree/v6.3.1" }, "funding": [ { @@ -9406,20 +9408,20 @@ "type": "tidelift" } ], - "time": "2023-04-28T16:05:33+00:00" + "time": "2023-06-05T15:30:22+00:00" }, { "name": "symfony/maker-bundle", - "version": "v1.48.0", + "version": "v1.49.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "2e428e8432e9879187672fe08f1cc335e2a31dd6" + "reference": "ce1d424f76bbb377f1956cc7641e8e2eafe81cde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/2e428e8432e9879187672fe08f1cc335e2a31dd6", - "reference": "2e428e8432e9879187672fe08f1cc335e2a31dd6", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/ce1d424f76bbb377f1956cc7641e8e2eafe81cde", + "reference": "ce1d424f76bbb377f1956cc7641e8e2eafe81cde", "shasum": "" }, "require": { @@ -9433,7 +9435,8 @@ "symfony/filesystem": "^5.4.7|^6.0", "symfony/finder": "^5.4.3|^6.0", "symfony/framework-bundle": "^5.4.7|^6.0", - "symfony/http-kernel": "^5.4.7|^6.0" + "symfony/http-kernel": "^5.4.7|^6.0", + "symfony/process": "^5.4.7|^6.0" }, "conflict": { "doctrine/doctrine-bundle": "<2.4", @@ -9445,9 +9448,8 @@ "doctrine/doctrine-bundle": "^2.4", "doctrine/orm": "^2.10.0", "symfony/http-client": "^5.4.7|^6.0", - "symfony/phpunit-bridge": "^5.4.7|^6.0", + "symfony/phpunit-bridge": "^5.4.17|^6.0", "symfony/polyfill-php80": "^1.16.0", - "symfony/process": "^5.4.7|^6.0", "symfony/security-core": "^5.4.7|^6.0", "symfony/yaml": "^5.4.3|^6.0", "twig/twig": "^2.0|^3.0" @@ -9483,7 +9485,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.48.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.49.0" }, "funding": [ { @@ -9499,7 +9501,7 @@ "type": "tidelift" } ], - "time": "2022-11-14T10:48:46+00:00" + "time": "2023-06-07T13:10:14+00:00" }, { "name": "symfony/options-resolver", @@ -9570,16 +9572,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "f8d75b4d9bf7243979b2c2e5e6cd73f03e10579f" + "reference": "0b0bf59b0d9bd1422145a123a67fb12af546ef0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f8d75b4d9bf7243979b2c2e5e6cd73f03e10579f", - "reference": "f8d75b4d9bf7243979b2c2e5e6cd73f03e10579f", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/0b0bf59b0d9bd1422145a123a67fb12af546ef0d", + "reference": "0b0bf59b0d9bd1422145a123a67fb12af546ef0d", "shasum": "" }, "require": { @@ -9631,7 +9633,7 @@ "description": "Provides utilities for PHPUnit, especially user deprecation notices management", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v6.3.0" + "source": "https://github.com/symfony/phpunit-bridge/tree/v6.3.1" }, "funding": [ { @@ -9647,7 +9649,7 @@ "type": "tidelift" } ], - "time": "2023-05-30T09:01:24+00:00" + "time": "2023-06-23T13:25:16+00:00" }, { "name": "symfony/process", @@ -9712,16 +9714,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "bab614e12218a95a924685a1fbf662bd7ca2d746" + "reference": "4a6cf8cb093e720c7ae4d55b1af848ce7e9abd36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/bab614e12218a95a924685a1fbf662bd7ca2d746", - "reference": "bab614e12218a95a924685a1fbf662bd7ca2d746", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4a6cf8cb093e720c7ae4d55b1af848ce7e9abd36", + "reference": "4a6cf8cb093e720c7ae4d55b1af848ce7e9abd36", "shasum": "" }, "require": { @@ -9773,7 +9775,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.3.0" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.3.1" }, "funding": [ { @@ -9789,7 +9791,7 @@ "type": "tidelift" } ], - "time": "2023-05-22T17:08:58+00:00" + "time": "2023-06-24T11:51:27+00:00" }, { "name": "vimeo/psalm", diff --git a/api/config/packages/doctrine.yaml b/api/config/packages/doctrine.yaml index bdff96fef..88671ee21 100644 --- a/api/config/packages/doctrine.yaml +++ b/api/config/packages/doctrine.yaml @@ -8,6 +8,8 @@ doctrine: orm: auto_generate_proxy_classes: true enable_lazy_ghost_objects: true + report_fields_where_declared: true + validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true mappings: diff --git a/api/phpunit.xml.dist b/api/phpunit.xml.dist index 384449482..c1f971adf 100644 --- a/api/phpunit.xml.dist +++ b/api/phpunit.xml.dist @@ -36,14 +36,8 @@ - - - - - + + diff --git a/api/symfony.lock b/api/symfony.lock index 24927565f..e8aab7af5 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -6,17 +6,16 @@ "version": "v1.8.1" }, "api-platform/core": { - "version": "2.6", + "version": "3.1", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.5", - "ref": "05b57782a78c21a664a42055dc11cf1954ca36bb" + "version": "3.0", + "ref": "0330386d716d3eecc52ee5ac66976e733eb8f961" }, "files": [ - "config/packages/api_platform.yaml", "config/routes/api_platform.yaml", - "src/Entity/.gitignore" + "src/ApiResource/.gitignore" ] }, "api-platform/schema-generator": { @@ -40,7 +39,7 @@ "repo": "github.com/symfony/recipes-contrib", "branch": "main", "version": "4.0", - "ref": "56eaa387b5e48ebcc7c95a893b47dfa1ad51449c" + "ref": "2c920f73a217f30bd4a37833c91071f4d3dc1ecd" }, "files": [ "config/packages/test/dama_doctrine_test_bundle.yaml" @@ -77,12 +76,12 @@ "version": "v0.5.3" }, "doctrine/doctrine-bundle": { - "version": "2.7", + "version": "2.10", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.4", - "ref": "ddddd8249dd55bbda16fa7a45bb7499ef6f8e90e" + "version": "2.10", + "ref": "f0d8c9a4da17815830aac0d63e153a940ae176bb" }, "files": [ "config/packages/doctrine.yaml", @@ -103,12 +102,12 @@ ] }, "doctrine/doctrine-migrations-bundle": { - "version": "3.1", + "version": "3.2", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "3.1", - "ref": "ee609429c9ee23e22d6fa5728211768f51ed2818" + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" }, "files": [ "config/packages/doctrine_migrations.yaml", @@ -350,12 +349,12 @@ ] }, "symfony/framework-bundle": { - "version": "6.1", + "version": "6.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "5.4", - "ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb" + "version": "6.2", + "ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3" }, "files": [ "config/packages/cache.yaml", @@ -398,19 +397,19 @@ "repo": "github.com/symfony/recipes", "branch": "main", "version": "0.3", - "ref": "e0a854b5439186e04b28fb8887b42c54f24a0d32" + "ref": "851667ac03fd113c5bd2215fb743974f6709bc62" }, "files": [ "config/packages/mercure.yaml" ] }, "symfony/messenger": { - "version": "6.1", + "version": "6.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "6.0", - "ref": "2523f7d31488903e247a522e760dc279be7f7aaf" + "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee" }, "files": [ "config/packages/messenger.yaml" @@ -438,12 +437,12 @@ "version": "v5.3.0" }, "symfony/phpunit-bridge": { - "version": "6.1", + "version": "6.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "5.3", - "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96" + "version": "6.3", + "ref": "01dfaa98c58f7a7b5a9b30e6edb7074af7ed9819" }, "files": [ ".env.test", @@ -474,12 +473,12 @@ "version": "v5.3.1" }, "symfony/routing": { - "version": "6.1", + "version": "6.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.1", - "ref": "a44010c0d06989bd4f154aa07d2542d47caf5b83" + "version": "6.2", + "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6" }, "files": [ "config/packages/routing.yaml", @@ -529,12 +528,12 @@ "version": "v5.3.0" }, "symfony/twig-bundle": { - "version": "6.1", + "version": "6.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "5.4", - "ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387" + "version": "6.3", + "ref": "b7772eb20e92f3fb4d4fe756e7505b4ba2ca1a2c" }, "files": [ "config/packages/twig.yaml", diff --git a/api/templates/base.html.twig b/api/templates/base.html.twig index d4f83f7f8..67598ac2c 100644 --- a/api/templates/base.html.twig +++ b/api/templates/base.html.twig @@ -4,13 +4,10 @@ {% block title %}Welcome!{% endblock %} - {# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #} {% block stylesheets %} - {{ encore_entry_link_tags('app') }} {% endblock %} {% block javascripts %} - {{ encore_entry_script_tags('app') }} {% endblock %} From b9f37b1e6341286d5ed62df1f731188745b0fd0d Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 29 Jun 2023 16:52:48 +0200 Subject: [PATCH 03/51] feat: prepare schema --- api/.env | 7 - api/composer.json | 4 +- api/composer.lock | 1116 +++-------------- api/config/packages/api_platform.yaml | 16 - api/config/packages/messenger.yaml | 24 - api/migrations/Version20210420202107.php | 41 - api/migrations/Version20230707185549.php | 65 + api/src/Controller/LegacyApiController.php | 30 - api/src/Controller/ProfileController.php | 21 - api/src/DataFixtures/AppFixtures.php | 10 +- api/src/DataFixtures/Factory/BookFactory.php | 10 +- .../DataFixtures/Factory/DownloadFactory.php | 85 ++ .../DataFixtures/Factory/ReviewFactory.php | 9 +- api/src/DataFixtures/Factory/UserFactory.php | 45 +- ...ultBooksStory.php => DefaultBookStory.php} | 5 +- .../Story/DefaultDownloadStory.php | 16 + .../Story/DefaultReviewsStory.php | 4 +- .../DataFixtures/Story/DefaultUsersStory.php | 5 + api/src/Entity/ArchivableInterface.php | 10 - api/src/Entity/ArchivableTrait.php | 25 - api/src/Entity/Book.php | 152 +-- api/src/Entity/Download.php | 78 ++ api/src/Entity/Parchment.php | 14 +- api/src/Entity/Review.php | 152 +-- api/src/Entity/TopBook.php | 117 -- api/src/Entity/User.php | 71 +- api/src/Enum/BookCondition.php | 39 + api/src/Enum/EnumApiResourceTrait.php | 34 + api/src/Filter/ArchivedFilter.php | 76 -- api/src/Handler/BookHandler.php | 79 -- api/src/Kernel.php | 2 + api/src/OpenApi/OpenApiFactory.php | 95 -- api/src/Repository/BookRepository.php | 66 + api/src/Repository/DownloadRepository.php | 66 + api/src/Repository/ReviewRepository.php | 66 + .../TopBook/TopBookCachedDataRepository.php | 33 - .../TopBook/TopBookDataInterface.php | 15 - .../TopBook/TopBookDataRepository.php | 96 -- .../TopBook/data/top-100-novel-sci-fi-fr.csv | 101 -- api/src/Repository/UserRepository.php | 47 +- api/src/Security/OidcTokenGenerator.php | 2 +- api/src/Serializer/BookNormalizer.php | 37 + .../TopBookCollectionExtensionInterface.php | 28 - .../Extension/TopBookPaginationExtension.php | 28 - api/src/State/TopBookCollectionProvider.php | 41 - api/src/State/TopBookItemProvider.php | 35 - api/symfony.lock | 54 - api/tests/Api/BooksTest.php | 257 ---- api/tests/Api/ReviewsTest.php | 148 --- api/tests/Api/SwaggerTest.php | 26 - api/tests/Api/TopBooksTest.php | 122 -- api/tests/Api/schemas/books.json | 215 ---- .../Controller/LegacyApiControllerTest.php | 36 - .../Controller/ProfileControllerTest.php | 66 - api/tests/Entity/TopBooksTest.php | 29 - docker-compose.yml | 20 - 56 files changed, 994 insertions(+), 3097 deletions(-) delete mode 100644 api/config/packages/messenger.yaml delete mode 100644 api/migrations/Version20210420202107.php create mode 100644 api/migrations/Version20230707185549.php delete mode 100644 api/src/Controller/LegacyApiController.php delete mode 100644 api/src/Controller/ProfileController.php create mode 100644 api/src/DataFixtures/Factory/DownloadFactory.php rename api/src/DataFixtures/Story/{DefaultBooksStory.php => DefaultBookStory.php} (60%) create mode 100644 api/src/DataFixtures/Story/DefaultDownloadStory.php delete mode 100644 api/src/Entity/ArchivableInterface.php delete mode 100644 api/src/Entity/ArchivableTrait.php create mode 100644 api/src/Entity/Download.php delete mode 100644 api/src/Entity/TopBook.php create mode 100644 api/src/Enum/BookCondition.php create mode 100644 api/src/Enum/EnumApiResourceTrait.php delete mode 100644 api/src/Filter/ArchivedFilter.php delete mode 100644 api/src/Handler/BookHandler.php delete mode 100644 api/src/OpenApi/OpenApiFactory.php create mode 100644 api/src/Repository/BookRepository.php create mode 100644 api/src/Repository/DownloadRepository.php create mode 100644 api/src/Repository/ReviewRepository.php delete mode 100644 api/src/Repository/TopBook/TopBookCachedDataRepository.php delete mode 100644 api/src/Repository/TopBook/TopBookDataInterface.php delete mode 100644 api/src/Repository/TopBook/TopBookDataRepository.php delete mode 100644 api/src/Repository/TopBook/data/top-100-novel-sci-fi-fr.csv create mode 100644 api/src/Serializer/BookNormalizer.php delete mode 100644 api/src/State/Extension/TopBookCollectionExtensionInterface.php delete mode 100644 api/src/State/Extension/TopBookPaginationExtension.php delete mode 100644 api/src/State/TopBookCollectionProvider.php delete mode 100644 api/src/State/TopBookItemProvider.php delete mode 100644 api/tests/Api/BooksTest.php delete mode 100644 api/tests/Api/ReviewsTest.php delete mode 100644 api/tests/Api/SwaggerTest.php delete mode 100644 api/tests/Api/TopBooksTest.php delete mode 100644 api/tests/Api/schemas/books.json delete mode 100644 api/tests/Controller/LegacyApiControllerTest.php delete mode 100644 api/tests/Controller/ProfileControllerTest.php delete mode 100644 api/tests/Entity/TopBooksTest.php diff --git a/api/.env b/api/.env index e6be5cb84..b278e4d6e 100644 --- a/api/.env +++ b/api/.env @@ -50,10 +50,3 @@ MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure # The secret used to sign the JWTs MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" ###< symfony/mercure-bundle ### - -###> symfony/messenger ### -# Choose one of the transports below -# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 -###< symfony/messenger ### diff --git a/api/composer.json b/api/composer.json index d1904601f..7e5438ed5 100644 --- a/api/composer.json +++ b/api/composer.json @@ -9,18 +9,17 @@ "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.12", + "myclabs/php-enum": "^1.8", "nelmio/cors-bundle": "^2.2", "phpstan/phpdoc-parser": "^1.16", "symfony/asset": "6.3.*", "symfony/console": "6.3.*", - "symfony/doctrine-messenger": "6.3.*", "symfony/dotenv": "6.3.*", "symfony/expression-language": "6.3.*", "symfony/flex": "^2.2", "symfony/framework-bundle": "6.3.*", "symfony/http-client": "6.3.*", "symfony/mercure-bundle": "^0.3.5", - "symfony/messenger": "6.3.*", "symfony/monolog-bundle": "^3.8", "symfony/property-access": "6.3.*", "symfony/property-info": "6.3.*", @@ -36,7 +35,6 @@ "webonyx/graphql-php": "^15.5" }, "require-dev": { - "api-platform/schema-generator": "^5.0", "dama/doctrine-test-bundle": "^7.2", "doctrine/doctrine-fixtures-bundle": "^3.4", "justinrainbow/json-schema": "^5.2", diff --git a/api/composer.lock b/api/composer.lock index 953fa6ddf..80d5ede6a 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "62103fddd24d279a6945c118d91431d9", + "content-hash": "39017a450ba5919f491be4dfe68d28bb", "packages": [ { "name": "api-platform/core", @@ -615,25 +615,29 @@ }, { "name": "doctrine/deprecations", - "version": "v1.1.0", + "version": "v1.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "8cffffb2218e01f3b370bf763e00e81697725259" + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/8cffffb2218e01f3b370bf763e00e81697725259", - "reference": "8cffffb2218e01f3b370bf763e00e81697725259", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", "shasum": "" }, "require": { - "php": "^7.1|^8.0" + "php": "^7.1 || ^8.0" }, "require-dev": { "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5|^8.5|^9.5", - "psr/log": "^1|^2|^3" + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -652,9 +656,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v1.1.0" + "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" }, - "time": "2023-05-29T18:55:17+00:00" + "time": "2023-06-03T09:27:29+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -950,28 +954,28 @@ }, { "name": "doctrine/inflector", - "version": "2.0.6", + "version": "2.0.8", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "d9d313a36c872fd6ee06d9a6cbcf713eaa40f024" + "reference": "f9301a5b2fb1216b2b08f02ba04dc45423db6bff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/d9d313a36c872fd6ee06d9a6cbcf713eaa40f024", - "reference": "d9d313a36c872fd6ee06d9a6cbcf713eaa40f024", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/f9301a5b2fb1216b2b08f02ba04dc45423db6bff", + "reference": "f9301a5b2fb1216b2b08f02ba04dc45423db6bff", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^10", + "doctrine/coding-standard": "^11.0", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", "phpstan/phpstan-strict-rules": "^1.3", "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25" + "vimeo/psalm": "^4.25 || ^5.4" }, "type": "library", "autoload": { @@ -1021,7 +1025,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.6" + "source": "https://github.com/doctrine/inflector/tree/2.0.8" }, "funding": [ { @@ -1037,7 +1041,7 @@ "type": "tidelift" } ], - "time": "2022-10-20T09:10:12+00:00" + "time": "2023-06-16T13:40:37+00:00" }, { "name": "doctrine/instantiator", @@ -1716,6 +1720,69 @@ ], "time": "2023-06-21T08:46:11+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/a867478eae49c9f59ece437ae7f9506bfaa27483", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "http://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.4" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2022-08-04T09:53:51+00:00" + }, { "name": "nelmio/cors-bundle", "version": "2.3.1", @@ -3045,78 +3112,6 @@ ], "time": "2023-06-18T20:33:34+00:00" }, - { - "name": "symfony/doctrine-messenger", - "version": "v6.3.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb", - "reference": "f1c253e24ae6d2bc4939b1439e074e6d2e73ecdb", - "shasum": "" - }, - "require": { - "doctrine/dbal": "^2.13|^3.0", - "php": ">=8.1", - "symfony/messenger": "^5.4|^6.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "doctrine/persistence": "<1.3" - }, - "require-dev": { - "doctrine/persistence": "^1.3|^2|^3", - "symfony/property-access": "^5.4|^6.0", - "symfony/serializer": "^5.4|^6.0" - }, - "type": "symfony-messenger-bridge", - "autoload": { - "psr-4": { - "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Doctrine Messenger Bridge", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v6.3.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-06-24T11:51:27+00:00" - }, { "name": "symfony/dotenv", "version": "v6.3.0", @@ -3487,16 +3482,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "97b698e1d77d356304def77a8d0cd73090b359ea" + "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/97b698e1d77d356304def77a8d0cd73090b359ea", - "reference": "97b698e1d77d356304def77a8d0cd73090b359ea", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edd36776956f2a6fcf577edb5b05eb0e3bdc52ae", + "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae", "shasum": "" }, "require": { @@ -3530,7 +3525,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.3.0" + "source": "https://github.com/symfony/filesystem/tree/v6.3.1" }, "funding": [ { @@ -3546,7 +3541,7 @@ "type": "tidelift" } ], - "time": "2023-05-30T17:12:32+00:00" + "time": "2023-06-01T08:30:39+00:00" }, { "name": "symfony/finder", @@ -4348,91 +4343,6 @@ ], "time": "2023-05-23T16:31:37+00:00" }, - { - "name": "symfony/messenger", - "version": "v6.3.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/messenger.git", - "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/e92ae9997f36e1189ff8251636adc21b8c9a6bea", - "reference": "e92ae9997f36e1189ff8251636adc21b8c9a6bea", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/log": "^1|^2|^3", - "symfony/clock": "^6.3" - }, - "conflict": { - "symfony/event-dispatcher": "<5.4", - "symfony/event-dispatcher-contracts": "<2.5", - "symfony/framework-bundle": "<5.4", - "symfony/http-kernel": "<5.4", - "symfony/serializer": "<5.4" - }, - "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/console": "^5.4|^6.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/event-dispatcher": "^5.4|^6.0", - "symfony/http-kernel": "^5.4|^6.0", - "symfony/process": "^5.4|^6.0", - "symfony/property-access": "^5.4|^6.0", - "symfony/rate-limiter": "^5.4|^6.0", - "symfony/routing": "^5.4|^6.0", - "symfony/serializer": "^5.4|^6.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0", - "symfony/validator": "^5.4|^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Messenger\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Samuel Roze", - "email": "samuel.roze@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps applications send and receive messages to/from other applications or via message queues", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/messenger/tree/v6.3.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-06-21T12:08:28+00:00" - }, { "name": "symfony/monolog-bridge", "version": "v6.3.1", @@ -6568,16 +6478,16 @@ }, { "name": "twig/twig", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "106c170d08e8415d78be2d16c3d057d0d108262b" + "reference": "7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/106c170d08e8415d78be2d16c3d057d0d108262b", - "reference": "106c170d08e8415d78be2d16c3d057d0d108262b", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd", + "reference": "7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd", "shasum": "" }, "require": { @@ -6623,7 +6533,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.6.0" + "source": "https://github.com/twigphp/Twig/tree/v3.6.1" }, "funding": [ { @@ -6635,7 +6545,7 @@ "type": "tidelift" } ], - "time": "2023-05-03T19:06:57+00:00" + "time": "2023-06-08T12:52:13+00:00" }, { "name": "web-token/jwt-checker", @@ -7228,57 +7138,41 @@ "time": "2021-03-30T17:13:30+00:00" }, { - "name": "api-platform/schema-generator", - "version": "v5.2.0", + "name": "composer/package-versions-deprecated", + "version": "1.11.99.5", "source": { "type": "git", - "url": "https://github.com/api-platform/schema-generator.git", - "reference": "8b66b379ff1166c98824ff72237cbebd6c2c0256" + "url": "https://github.com/composer/package-versions-deprecated.git", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/schema-generator/zipball/8b66b379ff1166c98824ff72237cbebd6c2c0256", - "reference": "8b66b379ff1166c98824ff72237cbebd6c2c0256", + "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", "shasum": "" }, "require": { - "cebe/php-openapi": "^1.6", - "doctrine/inflector": "^1.4.3 || ^2.0", - "ext-json": "*", - "friendsofphp/php-cs-fixer": "^2.15 || ^3.0", - "league/html-to-markdown": "^5.0", - "nette/php-generator": "^3.6 || ^4.0", - "nette/utils": "^3.1 || ^4.0-dev", - "nikic/php-parser": "^4.13", - "php": ">=7.4", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "sweetrdf/easyrdf": "^1.6", - "symfony/config": "^5.2 || ^6.0", - "symfony/console": "^5.2 || ^6.0", - "symfony/filesystem": "^5.2 || ^6.0", - "symfony/string": "^5.2 || ^6.0", - "symfony/yaml": "^5.2 || ^6.0", - "twig/twig": "^3.0" + "composer-plugin-api": "^1.1.0 || ^2.0", + "php": "^7 || ^8" + }, + "replace": { + "ocramius/package-versions": "1.11.99" }, "require-dev": { - "api-platform/core": "^v2.7", - "doctrine/orm": "^2.7", - "myclabs/php-enum": "^1.7", - "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^1.2.0", - "symfony/doctrine-bridge": "^5.2 || ^6.0", - "symfony/finder": "^5.2 || ^6.0", - "symfony/phpunit-bridge": "^5.2 || ^6.0", - "symfony/serializer": "^5.2 || ^6.0", - "symfony/validator": "^5.2 || ^6.0" + "composer/composer": "^1.9.3 || ^2.0@dev", + "ext-zip": "^1.13", + "phpunit/phpunit": "^6.5 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "PackageVersions\\Installer", + "branch-alias": { + "dev-master": "1.x-dev" + } }, - "bin": [ - "bin/schema" - ], - "type": "library", "autoload": { "psr-4": { - "ApiPlatform\\SchemaGenerator\\": "src/" + "PackageVersions\\": "src/PackageVersions" } }, "notification-url": "https://packagist.org/downloads/", @@ -7287,183 +7181,47 @@ ], "authors": [ { - "name": "Kévin Dunglas", - "email": "dunglas@gmail.com" + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" } ], - "description": "Various tools to generate a data model based on Schema.org vocables", - "homepage": "https://api-platform.com/docs/schema-generator/", - "keywords": [ - "RDF", - "doctrine", - "entity", - "enum", - "model", - "owl", - "schema.org", - "semantic", - "symfony" - ], + "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", "support": { - "issues": "https://github.com/api-platform/schema-generator/issues", - "source": "https://github.com/api-platform/schema-generator/tree/v5.2.0" + "issues": "https://github.com/composer/package-versions-deprecated/issues", + "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" }, - "time": "2022-12-13T17:05:12+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-01-17T14:14:24+00:00" }, { - "name": "cebe/php-openapi", - "version": "1.7.0", + "name": "composer/pcre", + "version": "3.1.0", "source": { "type": "git", - "url": "https://github.com/cebe/php-openapi.git", - "reference": "020d72b8e3a9a60bc229953e93eda25c49f46f45" + "url": "https://github.com/composer/pcre.git", + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cebe/php-openapi/zipball/020d72b8e3a9a60bc229953e93eda25c49f46f45", - "reference": "020d72b8e3a9a60bc229953e93eda25c49f46f45", - "shasum": "" - }, - "require": { - "ext-json": "*", - "justinrainbow/json-schema": "^5.2", - "php": ">=7.1.0", - "symfony/yaml": "^3.4 || ^4 || ^5 || ^6" - }, - "conflict": { - "symfony/yaml": "3.4.0 - 3.4.4 || 4.0.0 - 4.4.17 || 5.0.0 - 5.1.9 || 5.2.0" - }, - "require-dev": { - "apis-guru/openapi-directory": "1.0.0", - "cebe/indent": "*", - "mermade/openapi3-examples": "1.0.0", - "nexmo/api-specification": "1.0.0", - "oai/openapi-specification": "3.0.3", - "phpstan/phpstan": "^0.12.0", - "phpunit/phpunit": "^6.5 || ^7.5 || ^8.5 || ^9.4" - }, - "bin": [ - "bin/php-openapi" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.6.x-dev" - } - }, - "autoload": { - "psr-4": { - "cebe\\openapi\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Carsten Brandt", - "email": "mail@cebe.cc", - "homepage": "https://cebe.cc/", - "role": "Creator" - } - ], - "description": "Read and write OpenAPI yaml/json files and make the content accessable in PHP objects.", - "homepage": "https://github.com/cebe/php-openapi#readme", - "keywords": [ - "openapi" - ], - "support": { - "issues": "https://github.com/cebe/php-openapi/issues", - "source": "https://github.com/cebe/php-openapi" - }, - "time": "2022-04-20T14:46:44+00:00" - }, - { - "name": "composer/package-versions-deprecated", - "version": "1.11.99.5", - "source": { - "type": "git", - "url": "https://github.com/composer/package-versions-deprecated.git", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1.0 || ^2.0", - "php": "^7 || ^8" - }, - "replace": { - "ocramius/package-versions": "1.11.99" - }, - "require-dev": { - "composer/composer": "^1.9.3 || ^2.0@dev", - "ext-zip": "^1.13", - "phpunit/phpunit": "^6.5 || ^7" - }, - "type": "composer-plugin", - "extra": { - "class": "PackageVersions\\Installer", - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "PackageVersions\\": "src/PackageVersions" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" - } - ], - "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", - "support": { - "issues": "https://github.com/composer/package-versions-deprecated/issues", - "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2022-01-17T14:14:24+00:00" - }, - { - "name": "composer/pcre", - "version": "3.1.0", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", - "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "url": "https://api.github.com/repos/composer/pcre/zipball/4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", "shasum": "" }, "require": { @@ -7774,82 +7532,6 @@ }, "time": "2019-12-04T15:06:13+00:00" }, - { - "name": "doctrine/annotations", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/annotations.git", - "reference": "e157ef3f3124bbf6fe7ce0ffd109e8a8ef284e7f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/e157ef3f3124bbf6fe7ce0ffd109e8a8ef284e7f", - "reference": "e157ef3f3124bbf6fe7ce0ffd109e8a8ef284e7f", - "shasum": "" - }, - "require": { - "doctrine/lexer": "^2 || ^3", - "ext-tokenizer": "*", - "php": "^7.2 || ^8.0", - "psr/cache": "^1 || ^2 || ^3" - }, - "require-dev": { - "doctrine/cache": "^2.0", - "doctrine/coding-standard": "^10", - "phpstan/phpstan": "^1.8.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^5.4 || ^6", - "vimeo/psalm": "^4.10" - }, - "suggest": { - "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Docblock Annotations Parser", - "homepage": "https://www.doctrine-project.org/projects/annotations.html", - "keywords": [ - "annotations", - "docblock", - "parser" - ], - "support": { - "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/2.0.1" - }, - "time": "2023-02-02T22:02:53+00:00" - }, { "name": "doctrine/data-fixtures", "version": "1.6.6", @@ -8184,102 +7866,6 @@ }, "time": "2022-03-02T22:36:06+00:00" }, - { - "name": "friendsofphp/php-cs-fixer", - "version": "v3.17.0", - "source": { - "type": "git", - "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3f0ed862f22386c55a767461ef5083bddceeed79" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f0ed862f22386c55a767461ef5083bddceeed79", - "reference": "3f0ed862f22386c55a767461ef5083bddceeed79", - "shasum": "" - }, - "require": { - "composer/semver": "^3.3", - "composer/xdebug-handler": "^3.0.3", - "doctrine/annotations": "^2", - "doctrine/lexer": "^2 || ^3", - "ext-json": "*", - "ext-tokenizer": "*", - "php": "^7.4 || ^8.0", - "sebastian/diff": "^4.0 || ^5.0", - "symfony/console": "^5.4 || ^6.0", - "symfony/event-dispatcher": "^5.4 || ^6.0", - "symfony/filesystem": "^5.4 || ^6.0", - "symfony/finder": "^5.4 || ^6.0", - "symfony/options-resolver": "^5.4 || ^6.0", - "symfony/polyfill-mbstring": "^1.27", - "symfony/polyfill-php80": "^1.27", - "symfony/polyfill-php81": "^1.27", - "symfony/process": "^5.4 || ^6.0", - "symfony/stopwatch": "^5.4 || ^6.0" - }, - "require-dev": { - "justinrainbow/json-schema": "^5.2", - "keradus/cli-executor": "^2.0", - "mikey179/vfsstream": "^1.6.11", - "php-coveralls/php-coveralls": "^2.5.3", - "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1", - "phpspec/prophecy": "^1.16", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "phpunitgoodpractices/polyfill": "^1.6", - "phpunitgoodpractices/traits": "^1.9.2", - "symfony/phpunit-bridge": "^6.2.3", - "symfony/yaml": "^5.4 || ^6.0" - }, - "suggest": { - "ext-dom": "For handling output formats in XML", - "ext-mbstring": "For handling non-UTF8 characters." - }, - "bin": [ - "php-cs-fixer" - ], - "type": "application", - "autoload": { - "psr-4": { - "PhpCsFixer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Dariusz Rumiński", - "email": "dariusz.ruminski@gmail.com" - } - ], - "description": "A tool to automatically fix PHP code style", - "keywords": [ - "Static code analysis", - "fixer", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.17.0" - }, - "funding": [ - { - "url": "https://github.com/keradus", - "type": "github" - } - ], - "time": "2023-05-22T19:59:32+00:00" - }, { "name": "justinrainbow/json-schema", "version": "5.2.12", @@ -8350,95 +7936,6 @@ }, "time": "2022-04-13T08:02:27+00:00" }, - { - "name": "league/html-to-markdown", - "version": "5.1.0", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/html-to-markdown.git", - "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", - "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-xml": "*", - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.1.0", - "phpstan/phpstan": "^0.12.99", - "phpunit/phpunit": "^8.5 || ^9.2", - "scrutinizer/ocular": "^1.6", - "unleashedtech/php-coding-standard": "^2.7", - "vimeo/psalm": "^4.22" - }, - "bin": [ - "bin/html-to-markdown" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.2-dev" - } - }, - "autoload": { - "psr-4": { - "League\\HTMLToMarkdown\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Lead Developer" - }, - { - "name": "Nick Cernis", - "email": "nick@cern.is", - "homepage": "http://modernnerd.net", - "role": "Original Author" - } - ], - "description": "An HTML-to-markdown conversion helper for PHP", - "homepage": "https://github.com/thephpleague/html-to-markdown", - "keywords": [ - "html", - "markdown" - ], - "support": { - "issues": "https://github.com/thephpleague/html-to-markdown/issues", - "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.0" - }, - "funding": [ - { - "url": "https://www.colinodell.com/sponsor", - "type": "custom" - }, - { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" - }, - { - "url": "https://github.com/colinodell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", - "type": "tidelift" - } - ], - "time": "2022-03-02T17:24:08+00:00" - }, { "name": "masterminds/html5", "version": "2.8.0", @@ -8557,174 +8054,18 @@ }, "time": "2023-04-09T17:37:40+00:00" }, - { - "name": "nette/php-generator", - "version": "v4.0.7", - "source": { - "type": "git", - "url": "https://github.com/nette/php-generator.git", - "reference": "de1843fbb692125e307937c85d43937d0dc0c1d4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/de1843fbb692125e307937c85d43937d0dc0c1d4", - "reference": "de1843fbb692125e307937c85d43937d0dc0c1d4", - "shasum": "" - }, - "require": { - "nette/utils": "^3.2.9 || ^4.0", - "php": ">=8.0 <8.3" - }, - "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", - "nette/tester": "^2.4", - "nikic/php-parser": "^4.15", - "phpstan/phpstan": "^1.0", - "tracy/tracy": "^2.8" - }, - "suggest": { - "nikic/php-parser": "to use ClassType::from(withBodies: true) & ClassType::fromCode()" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.2 features.", - "homepage": "https://nette.org", - "keywords": [ - "code", - "nette", - "php", - "scaffolding" - ], - "support": { - "issues": "https://github.com/nette/php-generator/issues", - "source": "https://github.com/nette/php-generator/tree/v4.0.7" - }, - "time": "2023-04-26T15:09:53+00:00" - }, - { - "name": "nette/utils", - "version": "v4.0.0", - "source": { - "type": "git", - "url": "https://github.com/nette/utils.git", - "reference": "cacdbf5a91a657ede665c541eda28941d4b09c1e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/cacdbf5a91a657ede665c541eda28941d4b09c1e", - "reference": "cacdbf5a91a657ede665c541eda28941d4b09c1e", - "shasum": "" - }, - "require": { - "php": ">=8.0 <8.3" - }, - "conflict": { - "nette/finder": "<3", - "nette/schema": "<1.2.2" - }, - "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", - "nette/tester": "^2.4", - "phpstan/phpstan": "^1.0", - "tracy/tracy": "^2.9" - }, - "suggest": { - "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", - "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", - "ext-json": "to use Nette\\Utils\\Json", - "ext-mbstring": "to use Strings::lower() etc...", - "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()", - "ext-xml": "to use Strings::length() etc. when mbstring is not available" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", - "homepage": "https://nette.org", - "keywords": [ - "array", - "core", - "datetime", - "images", - "json", - "nette", - "paginator", - "password", - "slugify", - "string", - "unicode", - "utf-8", - "utility", - "validation" - ], - "support": { - "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.0" - }, - "time": "2023-02-02T10:41:53+00:00" - }, { "name": "nikic/php-parser", - "version": "v4.15.5", + "version": "v4.16.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e" + "reference": "19526a33fb561ef417e822e85f08a00db4059c17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/11e2663a5bc9db5d714eedb4277ee300403b4a9e", - "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/19526a33fb561ef417e822e85f08a00db4059c17", + "reference": "19526a33fb561ef417e822e85f08a00db4059c17", "shasum": "" }, "require": { @@ -8765,9 +8106,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.5" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.16.0" }, - "time": "2023-05-19T20:20:00+00:00" + "time": "2023-06-25T14:52:30+00:00" }, { "name": "openlss/lib-array2xml", @@ -9056,86 +8397,6 @@ ], "time": "2023-05-07T05:35:17+00:00" }, - { - "name": "sweetrdf/easyrdf", - "version": "1.8.0", - "source": { - "type": "git", - "url": "https://github.com/sweetrdf/easyrdf.git", - "reference": "2c57de7380ed16f5017e95810bcd08c0dffae640" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sweetrdf/easyrdf/zipball/2c57de7380ed16f5017e95810bcd08c0dffae640", - "reference": "2c57de7380ed16f5017e95810bcd08c0dffae640", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "ext-pcre": "*", - "ext-xmlreader": "*", - "lib-libxml": "*", - "php": "^7.1|^8.0" - }, - "replace": { - "easyrdf/easyrdf": "1.0.*|1.1.*" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.0", - "laminas/laminas-http": "^2", - "ml/json-ld": "^1.0", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5|^8.5|^9.5", - "semsol/arc2": "^2.4", - "zendframework/zend-http": "^2" - }, - "type": "library", - "autoload": { - "psr-4": { - "EasyRdf\\": "lib" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nicholas Humfrey", - "email": "njh@aelius.com", - "homepage": "http://www.aelius.com/njh/", - "role": "Developer" - }, - { - "name": "Alexey Zakhlestin", - "email": "indeyets@gmail.com", - "homepage": "http://indeyets.ru/", - "role": "Developer" - }, - { - "name": "Konrad Abicht", - "email": "hi@inspirito.de", - "homepage": "http://inspirito.de/", - "role": "Maintainer, Developer" - } - ], - "description": "EasyRdf is a PHP library designed to make it easy to consume and produce RDF.", - "keywords": [ - "Linked Data", - "RDF", - "Semantic Web", - "Turtle", - "rdfa", - "sparql" - ], - "support": { - "issues": "https://github.com/sweetrdf/easyrdf/issues", - "source": "https://github.com/sweetrdf/easyrdf/tree/1.8.0" - }, - "time": "2023-01-16T11:43:21+00:00" - }, { "name": "symfony/browser-kit", "version": "v6.3.0", @@ -9503,73 +8764,6 @@ ], "time": "2023-06-07T13:10:14+00:00" }, - { - "name": "symfony/options-resolver", - "version": "v6.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/a10f19f5198d589d5c33333cffe98dc9820332dd", - "reference": "a10f19f5198d589d5c33333cffe98dc9820332dd", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.3.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-12T14:21:09+00:00" - }, { "name": "symfony/phpunit-bridge", "version": "v6.3.1", diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 6c023461a..95b3f35f6 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -8,23 +8,7 @@ api_platform: [A PWA](/) and [an admin](/admin) are consuming this API. graphql: graphql_playground: false - mapping: - paths: ['%kernel.project_dir%/src/Entity'] - formats: - jsonld: ['application/ld+json'] - jsonhal: ['application/hal+json'] - jsonapi: ['application/vnd.api+json'] - json: ['application/json'] - xml: ['application/xml', 'text/xml'] - yaml: ['application/x-yaml'] - csv: ['text/csv'] - html: ['text/html'] - patch_formats: - json: ['application/merge-patch+json'] - jsonapi: ['application/vnd.api+json'] - # Mercure integration, remove if unwanted mercure: ~ - # Good cache defaults for REST APIs defaults: stateless: true cache_headers: diff --git a/api/config/packages/messenger.yaml b/api/config/packages/messenger.yaml deleted file mode 100644 index 9cd48ce77..000000000 --- a/api/config/packages/messenger.yaml +++ /dev/null @@ -1,24 +0,0 @@ -framework: - messenger: - # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. - # failure_transport: failed - - transports: - # https://symfony.com/doc/current/messenger.html#transport-configuration - # async: '%env(MESSENGER_TRANSPORT_DSN)%' - # failed: 'doctrine://default?queue_name=failed' - # sync: 'sync://' - doctrine: '%env(MESSENGER_TRANSPORT_DSN)%' - - routing: - # Route your messages to the transports - # 'App\Message\YourMessage': async - App\Entity\Book: doctrine - -# when@test: -# framework: -# messenger: -# transports: -# # replace with your transport name here (e.g., my_transport: 'in-memory://') -# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test -# async: 'in-memory://' diff --git a/api/migrations/Version20210420202107.php b/api/migrations/Version20210420202107.php deleted file mode 100644 index 8a9290f3e..000000000 --- a/api/migrations/Version20210420202107.php +++ /dev/null @@ -1,41 +0,0 @@ -addSql('CREATE TABLE book (id UUID NOT NULL, isbn VARCHAR(255) DEFAULT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, author VARCHAR(255) NOT NULL, publication_date DATE NOT NULL, archived_at DATE, PRIMARY KEY(id))'); - $this->addSql('COMMENT ON COLUMN book.id IS \'(DC2Type:uuid)\''); - $this->addSql('CREATE TABLE parchment (id UUID NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, PRIMARY KEY(id))'); - $this->addSql('COMMENT ON COLUMN parchment.id IS \'(DC2Type:uuid)\''); - $this->addSql('CREATE TABLE review (id UUID NOT NULL, book_id UUID NOT NULL, body TEXT NOT NULL, rating SMALLINT NOT NULL, letter VARCHAR(255) DEFAULT NULL, author TEXT DEFAULT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE INDEX IDX_794381C616A2B381 ON review (book_id)'); - $this->addSql('COMMENT ON COLUMN review.id IS \'(DC2Type:uuid)\''); - $this->addSql('COMMENT ON COLUMN review.book_id IS \'(DC2Type:uuid)\''); - $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); - $this->addSql('COMMENT ON COLUMN "user".id IS \'(DC2Type:uuid)\''); - $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C616A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); - } - - public function down(Schema $schema): void - { - $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C616A2B381'); - $this->addSql('DROP TABLE book'); - $this->addSql('DROP TABLE parchment'); - $this->addSql('DROP TABLE review'); - $this->addSql('DROP TABLE "user"'); - } -} diff --git a/api/migrations/Version20230707185549.php b/api/migrations/Version20230707185549.php new file mode 100644 index 000000000..bd0518951 --- /dev/null +++ b/api/migrations/Version20230707185549.php @@ -0,0 +1,65 @@ +addSql('CREATE TABLE book (id UUID NOT NULL, book VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL, "condition" VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331CBE5A331 ON book (book)'); + $this->addSql('COMMENT ON COLUMN book.id IS \'(DC2Type:uuid)\''); + $this->addSql('CREATE TABLE download (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, downloaded_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_781A8270A76ED395 ON download (user_id)'); + $this->addSql('CREATE INDEX IDX_781A827016A2B381 ON download (book_id)'); + $this->addSql('COMMENT ON COLUMN download.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN download.user_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN download.book_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN download.downloaded_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE parchment (id UUID NOT NULL, title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN parchment.id IS \'(DC2Type:uuid)\''); + $this->addSql('CREATE TABLE review (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, published_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, body VARCHAR(255) NOT NULL, rating SMALLINT NOT NULL, letter VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_794381C6A76ED395 ON review (user_id)'); + $this->addSql('CREATE INDEX IDX_794381C616A2B381 ON review (book_id)'); + $this->addSql('COMMENT ON COLUMN review.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN review.user_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN review.book_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN review.published_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); + $this->addSql('COMMENT ON COLUMN "user".id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE download ADD CONSTRAINT FK_781A827016A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C616A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE download DROP CONSTRAINT FK_781A8270A76ED395'); + $this->addSql('ALTER TABLE download DROP CONSTRAINT FK_781A827016A2B381'); + $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C6A76ED395'); + $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C616A2B381'); + $this->addSql('DROP TABLE book'); + $this->addSql('DROP TABLE download'); + $this->addSql('DROP TABLE parchment'); + $this->addSql('DROP TABLE review'); + $this->addSql('DROP TABLE "user"'); + } +} diff --git a/api/src/Controller/LegacyApiController.php b/api/src/Controller/LegacyApiController.php deleted file mode 100644 index 48237e9f2..000000000 --- a/api/src/Controller/LegacyApiController.php +++ /dev/null @@ -1,30 +0,0 @@ -json([ - 'books_count' => 1000, - 'topbooks_count' => 100, - ]); - } -} diff --git a/api/src/Controller/ProfileController.php b/api/src/Controller/ProfileController.php deleted file mode 100644 index 6fc0c2dca..000000000 --- a/api/src/Controller/ProfileController.php +++ /dev/null @@ -1,21 +0,0 @@ -json($security->getUser()); - } -} diff --git a/api/src/DataFixtures/AppFixtures.php b/api/src/DataFixtures/AppFixtures.php index 5f3edb823..864073ae8 100644 --- a/api/src/DataFixtures/AppFixtures.php +++ b/api/src/DataFixtures/AppFixtures.php @@ -1,8 +1,11 @@ self::faker()->isbn13(), - 'title' => self::faker()->sentence(), - 'description' => self::faker()->paragraph(), + 'book' => self::faker()->unique()->url(), + 'title' => self::faker()->text(), 'author' => self::faker()->name(), - 'publicationDate' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'condition' => self::faker()->randomElement(BookCondition::getCases()), ]; } diff --git a/api/src/DataFixtures/Factory/DownloadFactory.php b/api/src/DataFixtures/Factory/DownloadFactory.php new file mode 100644 index 000000000..83d2bf218 --- /dev/null +++ b/api/src/DataFixtures/Factory/DownloadFactory.php @@ -0,0 +1,85 @@ + + * + * @method Download|Proxy create(array|callable $attributes = []) + * @method static Download|Proxy createOne(array $attributes = []) + * @method static Download|Proxy find(object|array|mixed $criteria) + * @method static Download|Proxy findOrCreate(array $attributes) + * @method static Download|Proxy first(string $sortedField = 'id') + * @method static Download|Proxy last(string $sortedField = 'id') + * @method static Download|Proxy random(array $attributes = []) + * @method static Download|Proxy randomOrCreate(array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method static Download[]|Proxy[] all() + * @method static Download[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Download[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static Download[]|Proxy[] findBy(array $attributes) + * @method static Download[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static Download[]|Proxy[] randomSet(int $number, array $attributes = []) + * + * @psalm-method Proxy create(array|callable $attributes = []) + * @psalm-method static Proxy createOne(array $attributes = []) + * @psalm-method static Proxy find(object|array|mixed $criteria) + * @psalm-method static Proxy findOrCreate(array $attributes) + * @psalm-method static Proxy first(string $sortedField = 'id') + * @psalm-method static Proxy last(string $sortedField = 'id') + * @psalm-method static Proxy random(array $attributes = []) + * @psalm-method static Proxy randomOrCreate(array $attributes = []) + * @psalm-method static RepositoryProxy repository() + * @psalm-method static list> all() + * @psalm-method static list> createMany(int $number, array|callable $attributes = []) + * @psalm-method static list> createSequence(iterable|callable $sequence) + * @psalm-method static list> findBy(array $attributes) + * @psalm-method static list> randomRange(int $min, int $max, array $attributes = []) + * @psalm-method static list> randomSet(int $number, array $attributes = []) + */ +final class DownloadFactory extends ModelFactory +{ + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + */ + public function __construct() + { + parent::__construct(); + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + */ + protected function getDefaults(): array + { + return [ + 'user' => lazy(fn () => UserFactory::randomOrCreate()), + 'book' => lazy(fn () => BookFactory::randomOrCreate()), + 'downloadedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): self + { + return $this + // ->afterInstantiate(function(Download $download): void {}) + ; + } + + protected static function getClass(): string + { + return Download::class; + } +} diff --git a/api/src/DataFixtures/Factory/ReviewFactory.php b/api/src/DataFixtures/Factory/ReviewFactory.php index c4dd1e0fc..72417c95e 100644 --- a/api/src/DataFixtures/Factory/ReviewFactory.php +++ b/api/src/DataFixtures/Factory/ReviewFactory.php @@ -1,5 +1,7 @@ lazy(fn () => UserFactory::randomOrCreate()), + 'book' => lazy(fn () => BookFactory::randomOrCreate()), + 'publishedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'body' => self::faker()->text(), 'rating' => self::faker()->numberBetween(0, 5), - 'book' => lazy(fn () => BookFactory::randomOrCreate()), - 'author' => self::faker()->name(), - 'publicationDate' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/api/src/DataFixtures/Factory/UserFactory.php b/api/src/DataFixtures/Factory/UserFactory.php index 58136cd10..00108cf97 100644 --- a/api/src/DataFixtures/Factory/UserFactory.php +++ b/api/src/DataFixtures/Factory/UserFactory.php @@ -1,9 +1,11 @@ * - * @method User|Proxy create(array|callable $attributes = []) - * @method static User|Proxy createOne(array $attributes = []) - * @method static User|Proxy find(object|array|mixed $criteria) - * @method static User|Proxy findOrCreate(array $attributes) - * @method static User|Proxy first(string $sortedField = 'id') - * @method static User|Proxy last(string $sortedField = 'id') - * @method static User|Proxy random(array $attributes = []) - * @method static User|Proxy randomOrCreate(array $attributes = []) - * @method static UserRepository|RepositoryProxy repository() - * @method static User[]|Proxy[] all() - * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static User[]|Proxy[] createSequence(iterable|callable $sequence) - * @method static User[]|Proxy[] findBy(array $attributes) - * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static User[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method User|Proxy create(array|callable $attributes = []) + * @method static User|Proxy createOne(array $attributes = []) + * @method static User|Proxy find(object|array|mixed $criteria) + * @method static User|Proxy findOrCreate(array $attributes) + * @method static User|Proxy first(string $sortedField = 'id') + * @method static User|Proxy last(string $sortedField = 'id') + * @method static User|Proxy random(array $attributes = []) + * @method static User|Proxy randomOrCreate(array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method static User[]|Proxy[] all() + * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static User[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static User[]|Proxy[] findBy(array $attributes) + * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static User[]|Proxy[] randomSet(int $number, array $attributes = []) * * @psalm-method Proxy create(array|callable $attributes = []) * @psalm-method static Proxy createOne(array $attributes = []) @@ -53,14 +55,21 @@ public function __construct() parent::__construct(); } + public static function createOneAdmin(array $attributes = []): User|Proxy + { + return static::createOne(['roles' => ['ROLE_ADMIN']] + $attributes); + } + /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories */ protected function getDefaults(): array { return [ - 'email' => self::faker()->email(), - 'roles' => [], + 'email' => self::faker()->unique()->email(), + 'firstName' => self::faker()->firstName(), + 'lastName' => self::faker()->lastName(), + 'roles' => ['ROLE_USER'], ]; } diff --git a/api/src/DataFixtures/Story/DefaultBooksStory.php b/api/src/DataFixtures/Story/DefaultBookStory.php similarity index 60% rename from api/src/DataFixtures/Story/DefaultBooksStory.php rename to api/src/DataFixtures/Story/DefaultBookStory.php index fb28c7cc1..5c6f49606 100644 --- a/api/src/DataFixtures/Story/DefaultBooksStory.php +++ b/api/src/DataFixtures/Story/DefaultBookStory.php @@ -1,15 +1,16 @@ new \DateTimeImmutable('2021-09-10')]); } } diff --git a/api/src/DataFixtures/Story/DefaultDownloadStory.php b/api/src/DataFixtures/Story/DefaultDownloadStory.php new file mode 100644 index 000000000..b9134a753 --- /dev/null +++ b/api/src/DataFixtures/Story/DefaultDownloadStory.php @@ -0,0 +1,16 @@ + 'admin@example.com', + 'firstName' => 'Chuck', + 'lastName' => 'Norris', 'roles' => ['ROLE_ADMIN'], ]); } diff --git a/api/src/Entity/ArchivableInterface.php b/api/src/Entity/ArchivableInterface.php deleted file mode 100644 index 7ceb9fd70..000000000 --- a/api/src/Entity/ArchivableInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -archivedAt = new \DateTimeImmutable(); - - return $this; - } - - public function getArchivedAt(): ?\DateTimeInterface - { - return $this->archivedAt; - } -} diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index fa305a9ea..93565bab9 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -4,9 +4,6 @@ namespace App\Entity; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -14,156 +11,93 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; -use ApiPlatform\Metadata\Put; -use ApiPlatform\Serializer\Filter\PropertyFilter; -use App\Filter\ArchivedFilter; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; +use App\Enum\BookCondition; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; /** - * @see https://schema.org/Book Documentation on Schema.org + * A book. + * + * @see https://schema.org/Book */ -#[ORM\Entity] #[ApiResource( - types: ['https://schema.org/Book'], + shortName: 'Book', + types: ['https://schema.org/Book', 'https://schema.org/Offer'], operations: [ new GetCollection(), - new Post(), new Get(), - new Put(), - new Patch(), - new Delete(security: 'is_granted("ROLE_ADMIN")'), - new Put( - uriTemplate: '/books/{id}/generate-cover{._format}', - normalizationContext: ['groups' => ['book:read', 'book:cover']], - security: 'is_granted("ROLE_USER")', - input: false, - output: false, - messenger: true, - ), + new Post(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), + new Patch(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), + new Delete(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), ], - normalizationContext: ['groups' => ['book:read']], - mercure: true, - paginationClientItemsPerPage: true + normalizationContext: ['groups' => ['Book:read', 'Enum:read']], + denormalizationContext: ['groups' => ['Book:write']], + mercure: true )] -#[ApiFilter(ArchivedFilter::class)] -#[ApiFilter(OrderFilter::class, properties: ['id', 'title', 'author', 'isbn', 'publicationDate'])] -#[ApiFilter(PropertyFilter::class)] -class Book implements ArchivableInterface +#[ORM\Entity] +#[UniqueEntity(fields: ['book'])] +class Book { - use ArchivableTrait; - + /** + * @see https://schema.org/identifier + */ + #[ApiProperty(identifier: true, types: ['https://schema.org/identifier'])] #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[Groups(groups: ['book:read'])] private ?Uuid $id = null; /** - * The ISBN of the book. + * @see https://schema.org/itemOffered */ - #[ORM\Column(nullable: true)] - #[ApiFilter(SearchFilter::class, strategy: 'exact')] - #[ApiProperty(types: ['https://schema.org/isbn'])] - #[Assert\Isbn] - #[Groups(groups: ['book:read'])] - public ?string $isbn = null; + #[ApiProperty(types: ['https://schema.org/itemOffered', 'https://purl.org/dc/terms/BibliographicResource'])] + #[Groups(groups: ['Book:read', 'Book:write'])] + #[Assert\NotBlank(allowNull: false)] + #[Assert\Url] + #[ORM\Column(unique: true)] + public ?string $book = null; /** - * The title of the book. + * @see https://schema.org/headline */ + #[ApiProperty(types: ['https://schema.org/headline'])] + #[Groups(groups: ['Book:read'])] #[ORM\Column] - #[ApiFilter(SearchFilter::class, strategy: 'ipartial')] - #[ApiProperty(types: ['https://schema.org/name'])] - #[Assert\NotBlank] - #[Groups(groups: ['book:read', 'review:read'])] public ?string $title = null; /** - * A description of the item. - */ - #[ORM\Column(type: 'text')] - #[ApiProperty(types: ['https://schema.org/description'])] - #[Assert\NotBlank] - #[Groups(groups: ['book:read'])] - public ?string $description = null; - - /** - * The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably. + * @see https://schema.org/author */ - #[ORM\Column] - #[ApiFilter(SearchFilter::class, strategy: 'ipartial')] #[ApiProperty(types: ['https://schema.org/author'])] - #[Assert\NotBlank] - #[Groups(groups: ['book:read'])] + #[Groups(groups: ['Book:read'])] + #[ORM\Column] public ?string $author = null; /** - * The date on which the CreativeWork was created or the item was added to a DataFeed. + * @see https://schema.org/OfferItemCondition */ - #[ORM\Column(type: 'date')] - #[ApiProperty(types: ['https://schema.org/dateCreated'])] + #[ApiProperty(types: ['https://schema.org/OfferItemCondition'])] + #[Groups(groups: ['Book:read', 'Book:write'])] #[Assert\NotNull] - #[Assert\Type(\DateTimeInterface::class)] - #[Groups(groups: ['book:read'])] - public ?\DateTimeInterface $publicationDate = null; + #[ORM\Column(name: '`condition`', type: 'string', enumType: BookCondition::class)] + public ?BookCondition $condition = null; /** - * The book's reviews. + * An IRI of reviews + * + * @see https://schema.org/reviews */ - #[ORM\OneToMany(mappedBy: 'book', targetEntity: Review::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ApiProperty(types: ['https://schema.org/reviews'])] - #[Groups(groups: ['book:read'])] - private Collection $reviews; - - /** - * The book's cover base64 encoded. - */ - #[ApiProperty(writable: false)] - #[Groups(groups: ['book:cover'])] - public ?string $cover = null; - - public function __construct() - { - $this->reviews = new ArrayCollection(); - } + #[Groups(groups: ['Book:read'])] + public ?string $reviews = null; public function getId(): ?Uuid { return $this->id; } - - public function addReview(Review $review, bool $updateRelation = true): void - { - if ($this->reviews->contains($review)) { - return; - } - - $this->reviews->add($review); - if ($updateRelation) { - $review->setBook($this, false); - } - } - - public function removeReview(Review $review, bool $updateRelation = true): void - { - $this->reviews->removeElement($review); - if ($updateRelation) { - $review->setBook(null, false); - } - } - - /** - * @return Collection - */ - public function getReviews(): iterable - { - return $this->reviews; - } } diff --git a/api/src/Entity/Download.php b/api/src/Entity/Download.php new file mode 100644 index 000000000..51aab50a7 --- /dev/null +++ b/api/src/Entity/Download.php @@ -0,0 +1,78 @@ + ['Download:read']], + denormalizationContext: ['groups' => ['Download:write']], + mercure: true +)] +class Download +{ + /** + * @see https://schema.org/identifier + */ + #[ORM\Id] + #[ORM\Column(type: UuidType::NAME, unique: true)] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + #[ApiProperty(types: ['https://schema.org/identifier'])] + private ?Uuid $id = null; + + /** + * @see https://schema.org/agent + */ + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + #[ApiProperty(types: ['https://schema.org/agent'])] + #[Groups(groups: ['Download:read'])] + public ?User $user = null; + + /** + * @see https://schema.org/object + */ + #[ORM\ManyToOne(targetEntity: Book::class)] + #[ORM\JoinColumn(nullable: false)] + #[ApiProperty(types: ['https://schema.org/object'])] + #[Groups(groups: ['Download:read', 'Download:write'])] + #[Assert\NotNull] + public ?Book $book = null; + + /** + * @see https://schema.org/startTime + */ + #[ORM\Column(type: 'datetime_immutable')] + #[ApiProperty(types: ['https://schema.org/startTime'])] + #[Groups(groups: ['Download:read'])] + public ?\DateTimeInterface $downloadedAt = null; + + public function getId(): ?Uuid + { + return $this->id; + } +} diff --git a/api/src/Entity/Parchment.php b/api/src/Entity/Parchment.php index 056960a5b..8c62ec1d5 100644 --- a/api/src/Entity/Parchment.php +++ b/api/src/Entity/Parchment.php @@ -4,20 +4,28 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; +/** + * @deprecated create a Book instead + */ #[ORM\Entity] #[ApiResource(deprecationReason: 'Create a Book instead')] class Parchment { + /** + * @see https://schema.org/identifier + */ #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + #[ApiProperty(types: ['https://schema.org/identifier'])] private ?Uuid $id = null; public function getId(): ?Uuid @@ -29,13 +37,13 @@ public function getId(): ?Uuid * The title of the book. */ #[ORM\Column] - #[Assert\NotBlank] + #[Assert\NotBlank(allowNull: false)] public ?string $title = null; /** * A description of the item. */ - #[ORM\Column(type: 'text')] - #[Assert\NotBlank] + #[ORM\Column] + #[Assert\NotBlank(allowNull: false)] public ?string $description = null; } diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index df39971c5..94e9a5456 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -4,13 +4,15 @@ namespace App\Entity; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; @@ -20,116 +22,122 @@ /** * A review of an item - for example, of a restaurant, movie, or store. * - * @see https://schema.org/Review Documentation on Schema.org + * @see https://schema.org/Review */ #[ORM\Entity] #[ApiResource( types: ['https://schema.org/Review'], - normalizationContext: ['groups' => ['review:read']], - denormalizationContext: ['groups' => ['review:write']], + operations: [ + new GetCollection(), + new Get(), + new Put(), + new Patch(), + new Delete(), + ], + routePrefix: '/admin', + normalizationContext: ['groups' => ['Review:read']], + denormalizationContext: ['groups' => ['Review:write']], mercure: true, + security: 'is_granted("ROLE_ADMIN")' )] -#[ApiResource( - uriTemplate: '/books/{id}/reviews', - types: ['https://schema.org/Review'], - operations: [new GetCollection()], - normalizationContext: ['groups' => ['review:read']], - paginationClientItemsPerPage: true, -)] -#[ApiFilter(OrderFilter::class, properties: ['id', 'publicationDate'])] #[ApiResource( uriTemplate: '/books/{bookId}/reviews.{_format}', types: ['https://schema.org/Review'], + operations: [ + new GetCollection(), + new Post(security: 'is_granted("ROLE_USER")'), + new Patch( + uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', + uriVariables: [ + 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), + 'id' => new Link(fromClass: Review::class), + ], + security: 'is_granted("ROLE_USER") and user == object.getUser()', + ), + new Delete( + uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', + uriVariables: [ + 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), + 'id' => new Link(fromClass: Review::class), + ], + security: 'is_granted("ROLE_USER") and user == object.getUser()', + ), + ], uriVariables: [ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), ], - normalizationContext: ['groups' => ['review:read']], - denormalizationContext: ['groups' => ['review:write']] + normalizationContext: ['groups' => ['Review:read']], + denormalizationContext: ['groups' => ['Review:write']] )] -#[GetCollection] class Review { + /** + * @see https://schema.org/identifier + */ #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[Groups(groups: ['book:read', 'review:read'])] + #[ApiProperty(types: ['https://schema.org/identifier'])] private ?Uuid $id = null; /** - * The actual body of the review. + * @see https://schema.org/author */ - #[ORM\Column(type: 'text')] - #[ApiProperty(types: ['https://schema.org/reviewBody'])] - #[Assert\NotBlank] - #[Groups(groups: ['book:read', 'review:read', 'review:write'])] - public ?string $body = null; + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + #[ApiProperty(types: ['https://schema.org/author'])] + #[Groups(groups: ['Review:read'])] + public ?User $user = null; /** - * A rating. + * @see https://schema.org/itemReviewed */ - #[ORM\Column(type: 'smallint')] - #[Assert\NotBlank] - #[Assert\Range(min: 0, max: 5)] - #[Groups(groups: ['review:read', 'review:write'])] - public ?int $rating = null; + #[ORM\ManyToOne(targetEntity: Book::class)] + #[ORM\JoinColumn(nullable: false)] + #[ApiProperty(types: ['https://schema.org/itemReviewed'])] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[Assert\NotNull] + public ?Book $book = null; /** - * DEPRECATED (use rating now): A letter to rate the book. + * @see https://schema.org/datePublished */ - #[ORM\Column(type: 'string', nullable: true)] - #[Assert\Choice(['a', 'b', 'c', 'd'])] - #[ApiProperty(deprecationReason: 'Use the rating property instead')] - #[Groups(groups: ['review:read', 'review:write'])] - public ?string $letter = null; + #[ORM\Column(type: 'datetime_immutable')] + #[ApiProperty(types: ['https://schema.org/datePublished'])] + #[Groups(groups: ['Review:read'])] + public ?\DateTimeInterface $publishedAt = null; /** - * The item that is being reviewed/rated. + * @see https://schema.org/reviewBody */ - #[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'reviews')] - #[ORM\JoinColumn(nullable: false)] - #[ApiFilter(SearchFilter::class)] - #[ApiProperty(types: ['https://schema.org/itemReviewed'])] - #[Assert\NotNull] - #[Groups(groups: ['review:read', 'review:write'])] - private ?Book $book = null; + #[ORM\Column] + #[ApiProperty(types: ['https://schema.org/reviewBody'])] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[Assert\NotBlank(allowNull: false)] + public ?string $body = null; /** - * The author of the review. + * @see https://schema.org/reviewRating */ - #[ORM\Column(type: 'text', nullable: true)] - #[ApiProperty(types: ['https://schema.org/author'])] - #[Groups(groups: ['review:read', 'review:write'])] - public ?string $author = null; + #[ORM\Column(type: 'smallint')] + #[ApiProperty(types: ['https://schema.org/reviewRating'])] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[Assert\NotNull] + #[Assert\Range(min: 0, max: 5)] + public ?int $rating = null; /** - * Publication date of the review. + * @deprecated use the rating property instead */ - #[ORM\Column(type: 'datetime', nullable: true)] - #[Groups(groups: ['review:read', 'review:write'])] - public ?\DateTimeInterface $publicationDate = null; + #[ORM\Column(nullable: true)] + #[ApiProperty(deprecationReason: 'Use the rating property instead.')] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[Assert\Choice(['a', 'b', 'c', 'd'])] + public ?string $letter = null; public function getId(): ?Uuid { return $this->id; } - - public function setBook(?Book $book, bool $updateRelation = true): void - { - $this->book = $book; - if (!$updateRelation) { - return; - } - - if (null === $book) { - return; - } - - $book->addReview($this, false); - } - - public function getBook(): ?Book - { - return $this->book; - } } diff --git a/api/src/Entity/TopBook.php b/api/src/Entity/TopBook.php deleted file mode 100644 index 3fd169997..000000000 --- a/api/src/Entity/TopBook.php +++ /dev/null @@ -1,117 +0,0 @@ -id; - } - - public function setId(int $id): TopBook - { - $this->id = $id; - - return $this; - } - - public function getTitle(): string - { - return $this->title; - } - - public function setTitle(string $title): TopBook - { - $this->title = $title; - - return $this; - } - - public function getAuthor(): string - { - return $this->author; - } - - public function setAuthor(string $author): TopBook - { - $this->author = $author; - - return $this; - } - - public function getPart(): string - { - return $this->part; - } - - public function setPart(string $part): TopBook - { - $this->part = $part; - - return $this; - } - - public function getPlace(): string - { - return $this->place; - } - - public function setPlace(string $place): TopBook - { - $this->place = $place; - - return $this; - } - - public function getBorrowCount(): int - { - return $this->borrowCount; - } - - public function setBorrowCount(int $borrowCount): TopBook - { - $this->borrowCount = $borrowCount; - - return $this; - } -} diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 4c3307475..73860c078 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -4,43 +4,76 @@ namespace App\Entity; -use App\Repository\UserRepository; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Uid\Uuid; -#[ORM\Entity(repositoryClass: UserRepository::class)] +/** + * A person (alive, dead, undead, or fictional). + * + * @see https://schema.org/Person + */ +#[ORM\Entity] #[ORM\Table(name: '`user`')] +#[ApiResource( + shortName: 'User', + types: ['https://schema.org/Person'], + operations: [ + new Get(uriTemplate: '/admin/users/{id}.{_format}', security: 'is_granted("ROLE_ADMIN")'), + ], + normalizationContext: ['groups' => ['User:read']] +)] +#[UniqueEntity('email')] class User implements UserInterface { + /** + * @see https://schema.org/identifier + */ #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + #[ApiProperty(types: ['https://schema.org/identifier'])] private ?Uuid $id = null; - #[ORM\Column(type: 'string', length: 180, unique: true)] - private ?string $email = null; + /** + * @see https://schema.org/email + */ + #[ORM\Column(unique: true)] + #[ApiProperty(types: ['https://schema.org/email'])] + #[Groups(groups: ['User:read'])] + public ?string $email = null; + + /** + * @see https://schema.org/givenName + */ + #[ORM\Column] + #[ApiProperty(types: ['https://schema.org/givenName'])] + #[Groups(groups: ['User:read'])] + public ?string $firstName = null; + + /** + * @see https://schema.org/familyName + */ + #[ORM\Column] + #[ApiProperty(types: ['https://schema.org/familyName'])] + #[Groups(groups: ['User:read'])] + public ?string $lastName = null; #[ORM\Column(type: 'json')] - private array $roles = []; + public array $roles = []; public function getId(): ?Uuid { return $this->id; } - public function getEmail(): ?string - { - return $this->email; - } - - public function setEmail(string $email): void - { - $this->email = $email; - } - public function eraseCredentials(): void { } @@ -53,14 +86,6 @@ public function getRoles(): array return $this->roles; } - /** - * @param array $roles - */ - public function setRoles(array $roles): void - { - $this->roles = $roles; - } - public function getUserIdentifier(): string { return (string) $this->email; diff --git a/api/src/Enum/BookCondition.php b/api/src/Enum/BookCondition.php new file mode 100644 index 000000000..de4a98427 --- /dev/null +++ b/api/src/Enum/BookCondition.php @@ -0,0 +1,39 @@ +name; + } + + #[Groups('Enum:read')] + public function getValue() + { + return $this->value; + } + + public static function getCases(): array + { + return self::cases(); + } + + public static function getCase(Operation $operation, array $uriVariables) + { + $name = $uriVariables['id'] ?? null; + + return self::tryFrom($name); + } +} diff --git a/api/src/Filter/ArchivedFilter.php b/api/src/Filter/ArchivedFilter.php deleted file mode 100644 index c4b2b8336..000000000 --- a/api/src/Filter/ArchivedFilter.php +++ /dev/null @@ -1,76 +0,0 @@ - $context - */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void - { - if (!is_a($resourceClass, ArchivableInterface::class, true)) { - throw new \InvalidArgumentException(sprintf("Can't apply the Archived filter on a resource (%s) not implementing the ArchivableInterface.", $resourceClass)); - } - - // Parameter not provided or not supported - $archivedValue = $this->normalizeValue($context['filters'][self::PARAMETER_NAME] ?? null); - if (null === $archivedValue) { - return; - } - - $alias = $queryBuilder->getRootAliases()[0] ?? 'o'; - if ($archivedValue) { - $queryBuilder->andWhere(sprintf('%s.archivedAt IS NOT NULL', $alias)); - } else { - $queryBuilder->andWhere(sprintf('%s.archivedAt IS NULL', $alias)); - } - } - - /** - * @return array{archived: array{property: string, type: string, required: false, openapi: array{description: string, name: string, type: string}}} - */ - public function getDescription(string $resourceClass): array - { - $description = 'Filter archived entities. "true" or "1" returns archived only. "false" or "0" returns not archived only.'; - - return [ - self::PARAMETER_NAME => [ - 'property' => self::PARAMETER_NAME, - 'type' => 'bool', - 'required' => false, - 'openapi' => [ - 'description' => $description, - 'name' => self::PARAMETER_NAME, - 'type' => 'bool', - ], - ], - ]; - } - - private function normalizeValue(mixed $value): ?bool - { - if (\in_array($value, [false, 'false', '0', 0], true)) { - return false; - } - - if (\in_array($value, [true, 'true', '1', 1], true)) { - return true; - } - - return null; - } -} diff --git a/api/src/Handler/BookHandler.php b/api/src/Handler/BookHandler.php deleted file mode 100644 index e6710bbda..000000000 --- a/api/src/Handler/BookHandler.php +++ /dev/null @@ -1,79 +0,0 @@ -client->request('GET', 'https://api.imgflip.com/get_memes'); - } catch (TransportExceptionInterface $transportException) { - $this->logger->error('Cannot call Imgflip API.', [ - 'error' => $transportException->getMessage(), - ]); - - return; - } - - try { - $contents = $response->toArray(); - } catch (DecodingExceptionInterface $exception) { - $this->logger->error('Invalid JSON from Imgflip API.', [ - 'error' => $exception->getMessage(), - ]); - - return; - } - - $imageUrl = $contents['data']['memes'][\mt_rand(0, 99)]['url']; - $imageContent = (string) \file_get_contents($imageUrl); - - // Set Book.cover image in base64 - $book->cover = \sprintf( - 'data:image/%s;base64,%s', - \pathinfo((string) $imageUrl, PATHINFO_EXTENSION), - \base64_encode($imageContent) - ); - - // Send message to Mercure hub - $update = new Update( - $this->iriConverter->getIriFromResource($book, UrlGeneratorInterface::ABS_URL), - $this->serializer->serialize( - $book, - ItemNormalizer::FORMAT, - $this->resourceMetadataCollectionFactory->create(Book::class) - ->getOperation('_api_/books/{id}/generate-cover{._format}_put') - ->getNormalizationContext() - ) - ); - $this->hub->publish($update); - } -} diff --git a/api/src/Kernel.php b/api/src/Kernel.php index 779cd1f2b..ad0fb4800 100644 --- a/api/src/Kernel.php +++ b/api/src/Kernel.php @@ -1,5 +1,7 @@ - */ -#[AsDecorator(decorates: 'api_platform.openapi.factory')] -final class OpenApiFactory implements OpenApiFactoryInterface -{ - public function __construct(private readonly OpenApiFactoryInterface $decorated) - { - } - - public function __invoke(array $context = []): OpenApi - { - $openApi = ($this->decorated)($context); - $paths = $openApi->getPaths(); - - $paths->addPath( - '/stats', - (new PathItem()) - ->withGet( - (new OpenApiOperation()) - ->withOperationId('get') - ->withTags(['Stats']) - ->withResponse( - Response::HTTP_OK, - (new OpenApiResponse()) - ->withContent(new \ArrayObject([ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'books_count' => [ - 'type' => 'integer', - 'example' => 997, - ], - 'topbooks_count' => [ - 'type' => 'integer', - 'example' => 101, - ], - ], - ], - ], - ])) - ) - ->withSummary('Retrieves the number of books and top books (legacy endpoint).') - ) - ); - $paths->addPath( - '/profile', - (new PathItem()) - ->withGet( - (new OpenApiOperation()) - ->withOperationId('get') - ->withTags(['Profile']) - ->withResponse( - Response::HTTP_OK, - (new OpenApiResponse()) - ->withContent(new \ArrayObject([ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'string', - ], - 'email' => [ - 'type' => 'string', - ], - 'roles' => [ - 'type' => 'array', - ], - ], - ], - ], - ])) - ) - ) - ); - - return $openApi; - } -} diff --git a/api/src/Repository/BookRepository.php b/api/src/Repository/BookRepository.php new file mode 100644 index 000000000..da6fe1012 --- /dev/null +++ b/api/src/Repository/BookRepository.php @@ -0,0 +1,66 @@ + + * + * @method Book|null find($id, $lockMode = null, $lockVersion = null) + * @method Book|null findOneBy(array $criteria, array $orderBy = null) + * @method Book[] findAll() + * @method Book[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class BookRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Book::class); + } + + public function save(Book $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Book $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Book[] Returns an array of Book objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('b.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Book +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/api/src/Repository/DownloadRepository.php b/api/src/Repository/DownloadRepository.php new file mode 100644 index 000000000..cbf503307 --- /dev/null +++ b/api/src/Repository/DownloadRepository.php @@ -0,0 +1,66 @@ + + * + * @method Download|null find($id, $lockMode = null, $lockVersion = null) + * @method Download|null findOneBy(array $criteria, array $orderBy = null) + * @method Download[] findAll() + * @method Download[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class DownloadRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Download::class); + } + + public function save(Download $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Download $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Download[] Returns an array of Download objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('b.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Download +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/api/src/Repository/ReviewRepository.php b/api/src/Repository/ReviewRepository.php new file mode 100644 index 000000000..a40f34a56 --- /dev/null +++ b/api/src/Repository/ReviewRepository.php @@ -0,0 +1,66 @@ + + * + * @method Review|null find($id, $lockMode = null, $lockVersion = null) + * @method Review|null findOneBy(array $criteria, array $orderBy = null) + * @method Review[] findAll() + * @method Review[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ReviewRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Review::class); + } + + public function save(Review $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Review $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Review[] Returns an array of Review objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('b.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Review +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/api/src/Repository/TopBook/TopBookCachedDataRepository.php b/api/src/Repository/TopBook/TopBookCachedDataRepository.php deleted file mode 100644 index c0ecf01e2..000000000 --- a/api/src/Repository/TopBook/TopBookCachedDataRepository.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * @throws \InvalidArgumentException - */ - public function getTopBooks(): array - { - return $this->cache->get('books.sci-fi.top.fr', function (): array { - return $this->repository->getTopBooks(); - }); - } -} diff --git a/api/src/Repository/TopBook/TopBookDataInterface.php b/api/src/Repository/TopBook/TopBookDataInterface.php deleted file mode 100644 index cf6734afb..000000000 --- a/api/src/Repository/TopBook/TopBookDataInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - public function getTopBooks(): array; -} diff --git a/api/src/Repository/TopBook/TopBookDataRepository.php b/api/src/Repository/TopBook/TopBookDataRepository.php deleted file mode 100644 index 565a221f1..000000000 --- a/api/src/Repository/TopBook/TopBookDataRepository.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ - public function getTopBooks(): array - { - return $this->getFromCsv(); - } - - /** - * Be careful that the file is a simple csv file without "enclosure". That means - * a field can't contain a ";" or this would add an extra column to the row. - * Consider using a more robust library like csv reader from the PHP league. - * - * @see https://csv.thephpleague.com - * - * @return array - */ - public function getFromCsv(): array - { - $data = []; - foreach ($this->getFileAsArray() as $line) { - $data[] = str_getcsv($line, ';'); - } - - $cpt = 0; - foreach ($data as $row) { - if (1 === ++$cpt) { - continue; - } - - if (self::FIELDS_COUNT !== count($row)) { - throw new \RuntimeException(sprintf('Invalid data at row: %d', count($row))); - } - - $topBook = new TopBook( - $cpt - 1, - $this->sanitize($row[0] ?? ''), - $this->sanitize($row[1] ?? ''), - $this->sanitize($row[2] ?? ''), - $this->sanitize($row[3] ?? ''), - (int) ($row[4] ?? 0), - ); - $topBooks[$cpt - 1] = $topBook; - } - - return $topBooks ?? []; - } - - /** - * @return array - */ - private function getFileAsArray(): array - { - $csvFileName = __DIR__.'/data/'.self::DATA_SOURCE; - if (!is_file($csvFileName)) { - throw new \RuntimeException(sprintf("Can't find data source: %s", $csvFileName)); - } - - $file = file($csvFileName); - if (!is_array($file)) { - throw new \RuntimeException(sprintf("Can't load data source: %s", $csvFileName)); - } - - return $file; - } - - /** - * The CSV file is a "ISO-8859-1" encoded file with French accents. - */ - private function sanitize(?string $str): string - { - return trim((string) $str); - } -} diff --git a/api/src/Repository/TopBook/data/top-100-novel-sci-fi-fr.csv b/api/src/Repository/TopBook/data/top-100-novel-sci-fi-fr.csv deleted file mode 100644 index 6d21057e7..000000000 --- a/api/src/Repository/TopBook/data/top-100-novel-sci-fi-fr.csv +++ /dev/null @@ -1,101 +0,0 @@ -Titre;Auteur;Titre et N° de partie;Emplacement;Nombre de prêts en 2018 -Depuis l'au-delà;Werber Bernard;;F WER;9 -1984;Orwell George;;SF ORW;9 -Walking Dead;Kirkman Robert;T.01 l'ascension du gouverneur;F KIR;8 -La trilogie du rempart sud;VanderMeer Jeff;Tome 01 Annihilation;SF VAN;8 -Fahrenheit 451;Bradbury Ray;;SF BRA;7 -Revival;King Stephen;;F KIN;7 -Les ferailleurs;Carey Edward;T.01 le château;F CAR;7 -L'étoile de Pandore;Hamilton Peter F.;Tome 03 Judas déchaîné;SF HAM;7 -Possession;Tremblay Paul;;F TRE;7 -Lux;Armentrout Jennifer L.;T.01 Obsidienne;F ARM;7 -Le meilleur des mondes;Huxley Aldous;;SF HUX;7 -Le cycle de Dune;Herbert Frank;T.01 Dune;SF HER;7 -Altered carbon;Morgan Richard K.;Tome 01 carbone modifié;SF MOR;7 -Le cycle de Fondation;Asimov Isaac;Tome 03 Fondation;SF ASI;7 -Dreamcatcher;King Stephen;;F KIN;7 -1984;Orwell George;;SF ORW;6 -H2G2;Adams Douglas;Tome 02 le dernier restaurant avant la fin du monde;SF ADA;6 -L'étoile de Pandore;Hamilton Peter F.;Tome 04 Judas démasqué;SF HAM;6 -La mer éclatée;Abercrombie Joe;Tome 01 la Moitié d'un roi;F ABE;6 -Le dernier apprenti sorcier;Aaronovitch Ben;T. 01 les rivières de Londres;F AAR;6 -Le bazar des mauvais rêves;King Stephen;;F KIN;6 -Qui a peur de la mort ?;Okorafor-Mbachu Nnedi;;SF OKO;6 -Les Archives de Roshar;Sanderson Brandon;T.01 la Voie des rois;F SAN;6 -La Ferme des animaux;Orwell George;;SF ORW;6 -1984;Orwell George;;SF ORW;6 -Le grand secret;Barjavel René;;SF BAR;6 -Les étoiles de Noss Head;Jomain Sophie;T.04 origines;F JOM F JOM;6 -Les chevaliers d'Emeraude;Robillard Anne;Tome 01 le feu dans le ciel;F ROB;6 -Le Horla et six contes fantastiques;Maupassant Guy de;;MAU L;6 -Lux;Armentrout Jennifer L.;T.03 opale;F ARM F ARM;6 -Le cycle des fourmis;Werber Bernard;Tome 01 Les fourmis;F WER;6 -Les Dépossédés;Le Guin Ursula K.;;F LEG;5 -Le cycle d'Ender;Card Orson Scott;Tome 01 la stratégie Ender;SF CAR;5 -L'ascension de la maison Aubépine;Bodard Aliette de;;F BOD;5 -La flotte perdue;Campbell Jack;Tome 01 indomptable;SF CAM;5 -Le Paris des Merveilles;Pevel Pierre;Tome 01 Les enchantements d'Ambremer;F PEV;5 -Altered carbon;Morgan Richard K.;Tome 02 anges déchus;SF MOR;5 -Le seigneur des anneaux;Tolkien John Ronald Reuel;Tome 01 la communauté de l'anneau;F TOL;5 -Nous sommes Bob;Taylor Dennis E.;T.01 nous sommes légion;SF TAY;5 -Le dragon sous la mer;Herbert Frank;;SF HER;5 -L'assassin royal;Hobb Robin;Tome 01 l'apprenti assassin;F HOB;5 -Neverwhere;Gaiman Neil;;F GAI;5 -Lux;Armentrout Jennifer L.;T.02 onyx;F ARM;5 -La guerre des mondes;Wells Herbert George;;SF WEL;5 -Les légions de poussière;Sanderson Brandon;;F SAN;5 -Sorceleur;Sapkowski Andrzej;Tome 07 la Dame du Lac;F SAP;5 -La plaie;Henneberg Nathalie;;SF HEN;5 -Aristote et Dante découvrent les secrets de l'univers;Sàenz Benjamin Alire;;F SAE;5 -Le cycle de Syffe;Dewdney Patrick K.;Tome 01 L'enfant de poussière;F DEW;5 -Spin;Wilson Robert Charles;Tome 01;SF WIL;5 -L'assassin royal;Hobb Robin;Tome 06 la reine solitaire;F HOB;5 -L'Empire brisé;Lawrence Mark;Tome 01 le prince écorché;F LAW;5 -Les Lames du Cardinal;Pevel Pierre;;F PEV;5 -Le meilleur des mondes;Huxley Aldous;;SF HUX;5 -La boîte de Pandore;Werber Bernard;;F WER;5 -Blood Song;Ryan Anthony;Tome 02 Le Seigneur de la Tour;F RYA;5 -Le fini des mers;Dozois Gardner;;SF DOZ;5 -Jardin d'hiver;Paquet Olivier;;SF PAQ;5 -Sorceleur;Sapkowski Andrzej;Tome 06 la Tour de l'Hirondelle;F SAP;5 -L'empire brisé;Lawrence Mark;tome 02 le roi écorché;F LAW;5 -Le cycle des robots;Asimov Isaac;Tome 01 les robots;SF ASI;5 -La trilogie des guerriers du silence;Bordage Pierre;;F BOR;5 -Les chevaliers d'émeraude;Robillard Anne;Tome 05 l'île des lézards;F ROB;5 -Les Laissés-pour-compte;Eddings David;;F EDD;4 -L'Héritage des Rois-Passeurs;Fargetton Manon;;F FAR;4 -Les geôliers;Brussolo Serge;;F BRU;4 -Janus;Reynolds Alastair;;SF REY;4 -La Grande Route du Nord;Hamilton Peter F.;Tome 01;SF HAM;4 -L'assassin royal;Hobb Robin;Tome 01 l'apprenti assassin;F HOB;4 -Le Reich de la Lune;Sinisalo Johanna;;SF SIN;4 -Futu.Re;Gluhovskij Dmitrij Alekseevi?;;SF GLU;4 -Silo;Howey Hugh;tome 01;SF HOW;4 -Le Seigneur des Anneaux l'intégrale;Tolkien John Ronald Reuel;;F TOL;4 -Le cycle de Fondation;Asimov Isaac;Tome 02 l'aube de Fondation;SF ASI fon;4 -Ubik;Dick Philip Kindred;;SF DIC;4 -La mer éclatée;Abercrombie Joe;Tome 03 la Moitié d'une guerre;F ABE;4 -La Mallorée;Eddings David;Tome 03 le démon majeur de Karanda;F EDD;4 -La Mallorée;Eddings David;Tome 02 le roi des murgos;F EDD;4 -Dark matter;Crouch Blake;;SF CRO;4 -La Mallorée;Eddings David;Tome 01 les gardiens du ponant;F EDD;4 -Les enfants de la destinée;Baxter Stephen;Tome 01 coalescence;SF BAX;4 -Complications;Allan Nina;;SF ALL;4 -Cérès et Vesta;Egan Greg;;SF EGA;4 -La mer éclatée;Abercrombie Joe;Tome 02 la Moitié d'un monde;F ABE;4 -Le projet Mars;Eschbach Andreas;;SF ESC;4 -L'assassin royal;Hobb Robin;Tome 04 le poison de la vengeance;F HOB F HOB;4 -Orcs;Nicholls Stan;Tome 01 la Compagnie de la foudre;F NIC;4 -Le Ferry;Strandberg Mats;;F STR;4 -Au bal des actifs;;;SF AUB;4 -Walking Dead;Kirkman Robert;T.08 retour à Woodbury;F KIR;4 -Le cycle de Fondation;Asimov Isaac;Tome 04 Fondation et Empire;SF ASI;4 -Le plus heureux de tous les enfants décédés;Williams Tad;;SF WIL;4 -Je suis une légende;Matheson Richard;;SF MAT;4 -L'Essence de l'art;Banks Iain;/ Iain-M Banks;SF BAN;4 -Les étoiles de Noss Head;Jomain Sophie;T.01 vertige;F JOM;4 -La Communauté du Sud;Harris Charlaine;Tome 02 disparition à Dallas;F HAR;4 -La communauté du sud;Harris Charlaine;Tome 01 quand le danger rôde;F HAR;4 -Les étoiles de Noss Head;Jomain Sophie;T.05 Origines;F JOM;4 -La fleur de verre;Martin George R. R.;;SF MAR;4 -Fahrenheit 451;Bradbury Ray;;SF BRA;4 diff --git a/api/src/Repository/UserRepository.php b/api/src/Repository/UserRepository.php index c40f59d6a..b03c97dd6 100644 --- a/api/src/Repository/UserRepository.php +++ b/api/src/Repository/UserRepository.php @@ -1,7 +1,5 @@ + * * @method User|null find($id, $lockMode = null, $lockVersion = null) * @method User|null findOneBy(array $criteria, array $orderBy = null) * @method User[] findAll() @@ -20,4 +20,47 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } + + public function save(User $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(User $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return User[] Returns an array of User objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('b.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?User +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } } diff --git a/api/src/Security/OidcTokenGenerator.php b/api/src/Security/OidcTokenGenerator.php index 88f5f2ce9..a2074ed0c 100644 --- a/api/src/Security/OidcTokenGenerator.php +++ b/api/src/Security/OidcTokenGenerator.php @@ -20,7 +20,7 @@ */ #[When('test')] #[Autoconfigure(public: true)] -final class OidcTokenGenerator +final readonly class OidcTokenGenerator { public function __construct( #[Autowire('@security.access_token_handler.oidc.signature.ES256')] diff --git a/api/src/Serializer/BookNormalizer.php b/api/src/Serializer/BookNormalizer.php new file mode 100644 index 000000000..4677190ae --- /dev/null +++ b/api/src/Serializer/BookNormalizer.php @@ -0,0 +1,37 @@ +normalizer->normalize($object, $format, $context + [static::class => true]) + [ + 'reviews' => $this->router->generate('_api_/books/{bookId}/reviews.{_format}_get_collection', [ + 'bookId' => $object->getId(), + ]), + ]; + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof Book && !isset($context[static::class]); + } +} diff --git a/api/src/State/Extension/TopBookCollectionExtensionInterface.php b/api/src/State/Extension/TopBookCollectionExtensionInterface.php deleted file mode 100644 index ad0002e60..000000000 --- a/api/src/State/Extension/TopBookCollectionExtensionInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - $collection - * @param array $context - * - * @return iterable - */ - public function getResult(array $collection, string $resourceClass, Operation $operation = null, array $context = []): iterable; - - /** - * Tells if the extension is enabled or not. - * - * @param array $context - */ - public function isEnabled(string $resourceClass = null, Operation $operation = null, array $context = []): bool; -} diff --git a/api/src/State/Extension/TopBookPaginationExtension.php b/api/src/State/Extension/TopBookPaginationExtension.php deleted file mode 100644 index cb02d43ae..000000000 --- a/api/src/State/Extension/TopBookPaginationExtension.php +++ /dev/null @@ -1,28 +0,0 @@ -pagination->getPagination($operation, $context); - - return new ArrayPaginator($collection, $offset, $itemPerPage); - } - - public function isEnabled(string $resourceClass = null, Operation $operation = null, array $context = []): bool - { - return $this->pagination->isEnabled($operation, $context); - } -} diff --git a/api/src/State/TopBookCollectionProvider.php b/api/src/State/TopBookCollectionProvider.php deleted file mode 100644 index be8cb86cf..000000000 --- a/api/src/State/TopBookCollectionProvider.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array - { - $resourceClass = $operation->getClass(); - - try { - $collection = $this->repository->getTopBooks(); - } catch (\Exception $exception) { - throw new RuntimeException(sprintf('Unable to retrieve top books from external source: %s', $exception->getMessage())); - } - - if (!$this->paginationExtension->isEnabled($resourceClass, $operation, $context)) { - return $collection; - } - - return $this->paginationExtension->getResult($collection, $resourceClass, $operation, $context); - } -} diff --git a/api/src/State/TopBookItemProvider.php b/api/src/State/TopBookItemProvider.php deleted file mode 100644 index 171a73138..000000000 --- a/api/src/State/TopBookItemProvider.php +++ /dev/null @@ -1,35 +0,0 @@ -repository->getTopBooks(); - } catch (\Exception $exception) { - throw new \RuntimeException(sprintf('Unable to retrieve top books from external source: %s', $exception->getMessage())); - } - - return $topBooks[$id] ?? null; - } -} diff --git a/api/symfony.lock b/api/symfony.lock index e8aab7af5..d47b33945 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -18,9 +18,6 @@ "src/ApiResource/.gitignore" ] }, - "api-platform/schema-generator": { - "version": "v3.0.0" - }, "composer/package-versions-deprecated": { "version": "1.11.99.2" }, @@ -48,15 +45,6 @@ "dnoegel/php-xdg-base-dir": { "version": "v0.1.1" }, - "doctrine/annotations": { - "version": "1.13", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "1.10", - "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" - } - }, "doctrine/cache": { "version": "1.11.3" }, @@ -150,18 +138,6 @@ "fig/link-util": { "version": "1.2.0" }, - "friendsofphp/php-cs-fixer": { - "version": "3.8", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "3.0", - "ref": "be2103eb4a20942e28a6dd87736669b757132435" - }, - "files": [ - ".php-cs-fixer.dist.php" - ] - }, "friendsofphp/proxy-manager-lts": { "version": "v1.0.5" }, @@ -183,9 +159,6 @@ "lcobucci/jwt": { "version": "4.1.4" }, - "league/html-to-markdown": { - "version": "4.10.0" - }, "monolog/monolog": { "version": "2.2.0" }, @@ -204,12 +177,6 @@ "netresearch/jsonmapper": { "version": "v4.0.0" }, - "nette/php-generator": { - "version": "v4.0.1" - }, - "nette/utils": { - "version": "v3.2.7" - }, "nikic/php-parser": { "version": "v4.10.5" }, @@ -243,9 +210,6 @@ "psr/http-factory": { "version": "1.0.1" }, - "psr/http-message": { - "version": "1.0.1" - }, "psr/link": { "version": "1.1.1" }, @@ -309,9 +273,6 @@ "symfony/doctrine-bridge": { "version": "v5.3.1" }, - "symfony/doctrine-messenger": { - "version": "v6.0.3" - }, "symfony/dom-crawler": { "version": "v5.3.0" }, @@ -403,18 +364,6 @@ "config/packages/mercure.yaml" ] }, - "symfony/messenger": { - "version": "6.3", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "6.0", - "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee" - }, - "files": [ - "config/packages/messenger.yaml" - ] - }, "symfony/monolog-bridge": { "version": "v5.3.0" }, @@ -430,9 +379,6 @@ "config/packages/monolog.yaml" ] }, - "symfony/options-resolver": { - "version": "v5.3.0" - }, "symfony/password-hasher": { "version": "v5.3.0" }, diff --git a/api/tests/Api/BooksTest.php b/api/tests/Api/BooksTest.php deleted file mode 100644 index 1a70d8a29..000000000 --- a/api/tests/Api/BooksTest.php +++ /dev/null @@ -1,257 +0,0 @@ -client = static::createClient(); - $router = static::getContainer()->get('api_platform.router'); - if (!$router instanceof Router) { - throw new \RuntimeException('api_platform.router service not found.'); - } - - $this->router = $router; - - // Load fixtures - DefaultBooksStory::load(); - } - - public function testGetCollection(): void - { - // The client implements Symfony HttpClient's `HttpClientInterface`, and the response `ResponseInterface` - $response = $this->client->request('GET', '/books'); - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/Book', - '@id' => '/books', - '@type' => 'hydra:Collection', - 'hydra:totalItems' => self::COUNT, - 'hydra:view' => [ - '@id' => '/books?page=1', - '@type' => 'hydra:PartialCollectionView', - 'hydra:first' => '/books?page=1', - 'hydra:last' => '/books?page=4', - 'hydra:next' => '/books?page=2', - ], - ]); - - // It works because the API returns test fixtures loaded by Alice - self::assertCount(self::ITEMS_PER_PAGE, $response->toArray()['hydra:member']); - - static::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/books.json')); - // Checks that the returned JSON is validated by the JSON Schema generated for this API Resource by API Platform - // This JSON Schema is also used in the generated OpenAPI spec - self::assertMatchesResourceCollectionJsonSchema(Book::class); - } - - public function testCreateBook(): void - { - $response = $this->client->request('POST', '/books', ['json' => [ - 'isbn' => '0099740915', - 'title' => "The Handmaid's Tale", - 'description' => "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", - 'author' => 'Margaret Atwood', - 'publicationDate' => '1985-07-31T00:00:00+00:00', - ]]); - - self::assertResponseStatusCodeSame(Response::HTTP_CREATED); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/Book', - '@type' => 'https://schema.org/Book', - 'isbn' => '0099740915', - 'title' => "The Handmaid's Tale", - 'description' => "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", - 'author' => 'Margaret Atwood', - 'publicationDate' => '1985-07-31T00:00:00+00:00', - 'reviews' => [], - ]); - self::assertMatchesRegularExpression('~^/books/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$~', $response->toArray()['@id']); - self::assertMatchesResourceItemJsonSchema(Book::class); - } - - public function testCreateInvalidBook(): void - { - $this->client->request('POST', '/books', ['json' => [ - 'isbn' => 'invalid', - ]]); - - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'isbn: This value is neither a valid ISBN-10 nor a valid ISBN-13. -title: This value should not be blank. -description: This value should not be blank. -author: This value should not be blank. -publicationDate: This value should not be null.', - ]); - } - - public function testPatchBook(): void - { - BookFactory::createOne(['isbn' => self::ISBN]); - - $iri = (string) $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $this->client->request('PATCH', $iri, [ - 'json' => [ - 'title' => 'updated title', - ], - 'headers' => [ - 'Content-Type' => 'application/merge-patch+json', - ], - ]); - - self::assertResponseIsSuccessful(); - self::assertJsonContains([ - '@id' => $iri, - 'isbn' => self::ISBN, - 'title' => 'updated title', - ]); - } - - public function testDeleteBook(): void - { - DefaultUsersStory::load(); - BookFactory::createOne(['isbn' => self::ISBN]); - - $iri = (string) $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - self::assertNotNull($iri); - $token = static::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - 'email' => 'admin@example.com', - ]); - $this->client->request('DELETE', $iri, ['auth_bearer' => $token]); - - self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); - self::assertNull( - // Through the container, you can access all your services from the tests, including the ORM, the mailer, remote API clients... - static::getContainer()->get('doctrine')->getRepository(Book::class)->findOneBy(['isbn' => self::ISBN]) - ); - } - - public function testGenerateCover(): void - { - DefaultUsersStory::load(); - BookFactory::createOne(['isbn' => self::ISBN]); - - $book = static::getContainer()->get('doctrine')->getRepository(Book::class)->findOneBy(['isbn' => self::ISBN]); - self::assertInstanceOf(Book::class, $book); - if (!$book instanceof Book) { - throw new \LogicException('Book not found.'); - } - - $token = static::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - 'email' => 'admin@example.com', - ]); - $this->client->request('PUT', $this->router->generate('_api_/books/{id}/generate-cover{._format}_put', ['id' => $book->getId()]), [ - 'json' => [], - 'auth_bearer' => $token, - ]); - self::assertResponseIsSuccessful(); - - $messengerReceiverLocator = static::getContainer()->get('messenger.receiver_locator'); - if (!$messengerReceiverLocator instanceof ServiceProviderInterface) { - throw new \RuntimeException('messenger.receiver_locator service not found.'); - } - } - - /** - * The filter is not applied by default on the Book collections. - */ - public function testArchivedFilterDefault(): void - { - $this->client->request('GET', '/books'); - self::assertResponseIsSuccessful(); - self::assertJsonContains([ - '@id' => '/books', - '@type' => 'hydra:Collection', - 'hydra:totalItems' => self::COUNT, - ]); - } - - public function archivedParameterProvider(): \iterator - { - // Only archived are returned - yield ['true', self::COUNT_ARCHIVED]; - yield ['1', self::COUNT_ARCHIVED]; - - // Incorrect value, no filter applied - yield ['', self::COUNT]; - yield ['true[]', self::COUNT]; - yield ['foobar', self::COUNT]; - - // archived items are excluded - yield ['false', self::COUNT_WITHOUT_ARCHIVED]; - yield ['0', self::COUNT_WITHOUT_ARCHIVED]; - } - - /** - * @dataProvider archivedParameterProvider - */ - public function testArchivedFilterParameter(string $archivedValue, int $count): void - { - $this->client->request('GET', '/books?archived='.$archivedValue); - self::assertResponseIsSuccessful(); - self::assertJsonContains([ - '@id' => '/books', - '@type' => 'hydra:Collection', - 'hydra:totalItems' => $count, - ]); - } -} diff --git a/api/tests/Api/ReviewsTest.php b/api/tests/Api/ReviewsTest.php deleted file mode 100644 index 9da5195fd..000000000 --- a/api/tests/Api/ReviewsTest.php +++ /dev/null @@ -1,148 +0,0 @@ -client = self::createClient(); - DefaultReviewsStory::load(); - } - - public function testFilterReviewsByBook(): void - { - $iri = $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $response = $this->client->request('GET', sprintf('/reviews?book=%s', $iri)); - self::assertCount(2, $response->toArray()['hydra:member']); - } - - public function testBookSubresource(): void - { - $iri = $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $response = $this->client->request('GET', sprintf('%s/reviews', $iri)); - self::assertCount(2, $response->toArray()['hydra:member']); - } - - public function testCreateInvalidReviewWithInvalidBody(): void - { - $iri = $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $this->client->request('POST', '/reviews', ['json' => [ - 'body' => '', - 'rating' => 3, - 'book' => $iri, - 'author' => null, - 'publicationDate' => null, - ]]); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'body: This value should not be blank.', - 'violations' => [ - [ - 'propertyPath' => 'body', - 'message' => 'This value should not be blank.', - ], - ], - ]); - } - - /** - * @see https://github.com/api-platform/demo/issues/164 - */ - public function testCreateInvalidReviewWithoutRating(): void - { - $iri = $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $this->client->request('POST', '/reviews', ['json' => [ - 'body' => 'bonjour', - // 'rating' => '', // missing rating - 'book' => $iri, - 'author' => 'COil', - 'publicationDate' => null, - ]]); - - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'rating: This value should not be blank.', - ]); - } - - public function testCreateInvalidReviewWithInvalidRating(): void - { - $iri = $this->findIriBy(Book::class, ['isbn' => self::ISBN]); - $this->client->request('POST', '/reviews', ['json' => [ - 'body' => 'bonjour', - 'rating' => 6, - 'book' => $iri, - 'author' => 'COil', - 'publicationDate' => null, - ]]); - - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'rating: This value should be between 0 and 5.', - ]); - } - - public function testCreateInvalidReviewWithInvalidBook(): void - { - $this->client->request('POST', '/reviews', ['json' => [ - 'body' => '', - 'rating' => 0, - 'book' => null, - 'author' => '', - 'publicationDate' => null, - ]]); - self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); - self::assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'Expected IRI or nested document for attribute "book", "NULL" given.', - ]); - - $this->client->request('POST', '/reviews', ['json' => [ - 'body' => '', - 'rating' => 0, - 'book' => '/invalid/book_iri', - 'author' => '', - 'publicationDate' => null, - ]]); - self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); - self::assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'Invalid IRI "/invalid/book_iri".', - ]); - } -} diff --git a/api/tests/Api/SwaggerTest.php b/api/tests/Api/SwaggerTest.php deleted file mode 100644 index 06dc64c0b..000000000 --- a/api/tests/Api/SwaggerTest.php +++ /dev/null @@ -1,26 +0,0 @@ -client = self::createClient(); - } - - public function testStats(): void - { - $this->client->request('GET', '/docs.json'); - self::assertResponseIsSuccessful(); - self::assertStringContainsString('/stats', (string) $this->client->getResponse()->getContent()); - self::assertStringContainsString('/profile', (string) $this->client->getResponse()->getContent()); - } -} diff --git a/api/tests/Api/TopBooksTest.php b/api/tests/Api/TopBooksTest.php deleted file mode 100644 index 6987a324d..000000000 --- a/api/tests/Api/TopBooksTest.php +++ /dev/null @@ -1,122 +0,0 @@ -client = static::createClient(); - DefaultBooksStory::load(); - } - - /** - * @see TopBookCollectionProvider::provide() - */ - public function testGetCollection(): void - { - $response = $this->client->request('GET', '/top_books'); - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/TopBook', - '@id' => '/top_books', - '@type' => 'hydra:Collection', - 'hydra:totalItems' => 100, - 'hydra:view' => [ - '@id' => '/top_books?page=1', - '@type' => 'hydra:PartialCollectionView', - 'hydra:first' => '/top_books?page=1', - 'hydra:last' => '/top_books?page=10', - 'hydra:next' => '/top_books?page=2', - ], - ]); - - // 10 is the "pagination_items_per_page" parameters configured in the TopBook ApiResource annotation. - self::assertCount(self::PAGINATION_ITEMS_PER_PAGE, $response->toArray()['hydra:member']); - - // Checks that the returned JSON is validated by the JSON Schema generated for this API Resource by API Platform - // This JSON Schema is also used in the generated OpenAPI spec - self::assertMatchesResourceCollectionJsonSchema(TopBook::class); - - // This 2nd call use the cache @see TopBookCachedDataRepository - $response = $this->client->request('GET', '/top_books'); - self::assertResponseIsSuccessful(); - self::assertCount(self::PAGINATION_ITEMS_PER_PAGE, $response->toArray()['hydra:member']); - } - - /** - * Nominal case. - * - * @see TopBookItemProvider::provide() - */ - public function testGetItem(): void - { - $this->client->request('GET', '/top_books/1'); - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonEquals([ - '@context' => '/contexts/TopBook', - '@id' => '/top_books/1', - '@type' => 'TopBook', - 'id' => 1, - 'title' => "Depuis l'au-delà", - 'author' => 'Werber Bernard', - 'part' => '', - 'place' => 'F WER', - 'borrowCount' => 9, - ]); - - self::assertMatchesResourceItemJsonSchema(TopBook::class); - } - - /** - * Error case n°1: invalid identifier. - * - * @see TopBookItemProvider::provide() - */ - public function testGetItemErrorIdIsNotAnInteger(): void - { - $this->client->request('GET', '/top_books/foo'); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - } - - /** - * Error case n°2: out of range identifier. - * - * @see TopBookItemProvider::provide() - */ - public function testGetItemErrorIdIsOutOfRange(): void - { - $this->client->request('GET', '/top_books/101'); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - } -} diff --git a/api/tests/Api/schemas/books.json b/api/tests/Api/schemas/books.json deleted file mode 100644 index f0039b976..000000000 --- a/api/tests/Api/schemas/books.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", - "type": "object", - "definitions": { - "Book:jsonld": { - "type": "object", - "description": "", - "additionalProperties": false, - "externalDocs": { - "url": "https:\/\/schema.org\/Book" - }, - "properties": { - "@id": { - "readOnly": true, - "type": "string" - }, - "@type": { - "readOnly": true, - "type": "string" - }, - "@context": { - "readOnly": true, - "type": "string" - }, - "id": { - "readOnly": true, - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "isbn": { - "description": "The ISBN of the book", - "externalDocs": { - "url": "https:\/\/schema.org\/isbn" - }, - "type": [ - "string", - "null" - ] - }, - "title": { - "description": "The title of the book", - "externalDocs": { - "url": "https:\/\/schema.org\/name" - }, - "type": "string" - }, - "description": { - "description": "A description of the item", - "externalDocs": { - "url": "https:\/\/schema.org\/description" - }, - "type": "string" - }, - "author": { - "description": "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", - "externalDocs": { - "url": "https:\/\/schema.org\/author" - }, - "type": "string" - }, - "publicationDate": { - "description": "The date on which the CreativeWork was created or the item was added to a DataFeed", - "externalDocs": { - "url": "https:\/\/schema.org\/dateCreated" - }, - "type": "string", - "format": "date-time" - }, - "cover": { - "writeOnly": true, - "description": "The book's cover base64 encoded", - "type": [ - "string", - "null" - ] - }, - "reviews": { - "description": "The book's reviews", - "externalDocs": { - "url": "https:\/\/schema.org\/reviews" - }, - "type": "array", - "items": { - "$ref": "#\/definitions\/Review:jsonld" - } - } - }, - "required": [ - "title", - "description", - "author", - "publicationDate" - ] - }, - "Review:jsonld": { - "type": "object", - "description": "A review of an item - for example, of a restaurant, movie, or store.", - "additionalProperties": false, - "externalDocs": { - "url": "https:\/\/schema.org\/Review" - }, - "required": [ - "body" - ], - "properties": { - "@context": { - "readOnly": true, - "type": "string" - }, - "@id": { - "readOnly": true, - "type": "string" - }, - "@type": { - "readOnly": true, - "type": "string" - }, - "id": { - "readOnly": true, - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "body": { - "description": "The actual body of the review", - "externalDocs": { - "url": "https:\/\/schema.org\/reviewBody" - }, - "type": "string" - } - } - } - }, - "properties": { - "hydra:member": { - "type": "array", - "items": { - "$ref": "#\/definitions\/Book:jsonld" - } - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": { - "type": "string", - "format": "iri-reference" - }, - "@type": { - "type": "string" - }, - "hydra:first": { - "type": "string", - "format": "iri-reference" - }, - "hydra:last": { - "type": "string", - "format": "iri-reference" - }, - "hydra:next": { - "type": "string", - "format": "iri-reference" - } - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "hydra:template": { - "type": "string" - }, - "hydra:variableRepresentation": { - "type": "string" - }, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "variable": { - "type": "string" - }, - "property": { - "type": [ - "string", - "null" - ] - }, - "required": { - "type": "boolean" - } - } - } - } - } - } - }, - "required": [ - "hydra:member" - ] -} diff --git a/api/tests/Controller/LegacyApiControllerTest.php b/api/tests/Controller/LegacyApiControllerTest.php deleted file mode 100644 index df33746d6..000000000 --- a/api/tests/Controller/LegacyApiControllerTest.php +++ /dev/null @@ -1,36 +0,0 @@ -client = self::createClient(); - } - - /** - * @see LegacyApiController::__invoke() - */ - public function testStats(): void - { - $this->client->request('GET', '/stats'); - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/json'); - self::assertJsonEquals([ - 'books_count' => 1000, - 'topbooks_count' => 100, - ]); - } -} diff --git a/api/tests/Controller/ProfileControllerTest.php b/api/tests/Controller/ProfileControllerTest.php deleted file mode 100644 index 52520e444..000000000 --- a/api/tests/Controller/ProfileControllerTest.php +++ /dev/null @@ -1,66 +0,0 @@ -client = self::createClient(); - } - - /** - * @see ProfileController::__invoke() - */ - public function testProfile(): void - { - DefaultUsersStory::load(); - - $token = static::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - 'email' => 'admin@example.com', - ]); - $this->client->request('GET', '/profile', [ - 'auth_bearer' => $token, - ]); - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/json'); - self::assertJsonContains([ - 'email' => 'admin@example.com', - 'roles' => ['ROLE_ADMIN'], - ]); - } - - /** - * Custom claim "email" is missing. - */ - public function testCannotGetProfileWithInvalidToken(): void - { - $token = static::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - ]); - $this->client->request('GET', '/profile', [ - 'auth_bearer' => $token, - ]); - self::assertResponseStatusCodeSame(401); - } -} diff --git a/api/tests/Entity/TopBooksTest.php b/api/tests/Entity/TopBooksTest.php deleted file mode 100644 index 62a0d38a0..000000000 --- a/api/tests/Entity/TopBooksTest.php +++ /dev/null @@ -1,29 +0,0 @@ -expectException(\TypeError::class); - new TopBook(1, 1, 1, 1, 1, 10); - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 82b72b9d4..a6fc1a936 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,26 +29,6 @@ services: OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo} OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://caddy/oidc/realms/demo} - consumer: - build: - context: ./api - target: app_php - cache_from: - - ${PHP_DOCKER_IMAGE:-api-platform/php:latest} - entrypoint: docker-php-entrypoint - command: bin/console messenger:consume - depends_on: - - database - restart: unless-stopped - healthcheck: - test: ['CMD', 'ps', 'aux', '|', 'egrep', '"\d+:\d+ php bin/console messenger:consume"'] - interval: 10s - timeout: 3s - retries: 3 - start_period: 30s - environment: - <<: *php-env - pwa: build: context: ./pwa From 562d18ff8b5236b34de90a693d3ff4ee78affa7b Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 5 Jul 2023 23:20:08 +0200 Subject: [PATCH 04/51] feat: add State Processors --- api/src/Entity/Book.php | 18 +++++-- api/src/Entity/Download.php | 3 +- api/src/Entity/Review.php | 6 ++- .../Exception/InvalidBnfResponseException.php | 9 ++++ .../State/Processor/BookPersistProcessor.php | 50 +++++++++++++++++++ .../Processor/DownloadPersistProcessor.php | 37 ++++++++++++++ .../Processor/ReviewPersistProcessor.php | 37 ++++++++++++++ 7 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 api/src/Exception/InvalidBnfResponseException.php create mode 100644 api/src/State/Processor/BookPersistProcessor.php create mode 100644 api/src/State/Processor/DownloadPersistProcessor.php create mode 100644 api/src/State/Processor/ReviewPersistProcessor.php diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 93565bab9..26bf146d7 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Enum\BookCondition; +use App\State\Processor\BookPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -30,9 +31,20 @@ operations: [ new GetCollection(), new Get(), - new Post(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), - new Patch(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), - new Delete(routePrefix: '/admin', security: 'is_granted("ROLE_ADMIN")'), + new Post( + routePrefix: '/admin', + security: 'is_granted("ROLE_ADMIN")', + processor: BookPersistProcessor::class + ), + new Patch( + routePrefix: '/admin', + security: 'is_granted("ROLE_ADMIN")', + processor: BookPersistProcessor::class + ), + new Delete( + routePrefix: '/admin', + security: 'is_granted("ROLE_ADMIN")' + ), ], normalizationContext: ['groups' => ['Book:read', 'Enum:read']], denormalizationContext: ['groups' => ['Book:write']], diff --git a/api/src/Entity/Download.php b/api/src/Entity/Download.php index 51aab50a7..8c0dba520 100644 --- a/api/src/Entity/Download.php +++ b/api/src/Entity/Download.php @@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; +use App\State\Processor\DownloadPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; @@ -26,7 +27,7 @@ operations: [ new GetCollection(security: 'is_granted("ROLE_USER")'), new GetCollection(uriTemplate: '/admin/downloads.{_format}', security: 'is_granted("ROLE_ADMIN")'), - new Post(security: 'is_granted("ROLE_USER")'), + new Post(security: 'is_granted("ROLE_USER")', processor: DownloadPersistProcessor::class), ], normalizationContext: ['groups' => ['Download:read']], denormalizationContext: ['groups' => ['Download:write']], diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index 94e9a5456..9b3113bed 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\State\Processor\ReviewPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; @@ -45,7 +46,7 @@ types: ['https://schema.org/Review'], operations: [ new GetCollection(), - new Post(security: 'is_granted("ROLE_USER")'), + new Post(security: 'is_granted("ROLE_USER")', processor: ReviewPersistProcessor::class), new Patch( uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', uriVariables: [ @@ -53,6 +54,7 @@ 'id' => new Link(fromClass: Review::class), ], security: 'is_granted("ROLE_USER") and user == object.getUser()', + processor: ReviewPersistProcessor::class ), new Delete( uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', @@ -60,7 +62,7 @@ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), 'id' => new Link(fromClass: Review::class), ], - security: 'is_granted("ROLE_USER") and user == object.getUser()', + security: 'is_granted("ROLE_USER") and user == object.getUser()' ), ], uriVariables: [ diff --git a/api/src/Exception/InvalidBnfResponseException.php b/api/src/Exception/InvalidBnfResponseException.php new file mode 100644 index 000000000..2b0b34e96 --- /dev/null +++ b/api/src/Exception/InvalidBnfResponseException.php @@ -0,0 +1,9 @@ +bnfClient->request(Request::METHOD_GET, $data->book); + $results = $this->decoder->decode($response->getContent(), 'xml'); + if (!$title = $results['notice']['record']['metadata']['oai_dc:dc']['dc:title'] ?? null) { + throw new InvalidBnfResponseException('Missing property "dc:title" in BNF API response.'); + } + if (!$publisher = $results['notice']['record']['metadata']['oai_dc:dc']['dc:publisher'] ?? null) { + throw new InvalidBnfResponseException('Missing property "dc:publisher" in BNF API response.'); + } + $data->title = $title; + $data->author = $publisher; + + // save entity + $this->repository->save($data, true); + + return $data; + } +} diff --git a/api/src/State/Processor/DownloadPersistProcessor.php b/api/src/State/Processor/DownloadPersistProcessor.php new file mode 100644 index 000000000..e458ce2be --- /dev/null +++ b/api/src/State/Processor/DownloadPersistProcessor.php @@ -0,0 +1,37 @@ +user = $this->security->getUser(); + $data->downloadedAt = new \DateTimeImmutable(); + + // save entity + $this->repository->save($data, true); + + return $data; + } +} diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php new file mode 100644 index 000000000..d589e9de7 --- /dev/null +++ b/api/src/State/Processor/ReviewPersistProcessor.php @@ -0,0 +1,37 @@ +user = $this->security->getUser(); + $data->publishedAt = new \DateTimeImmutable(); + + // save entity + $this->repository->save($data, true); + + return $data; + } +} From c7e39037d67227a53b3264eb6de01fed12cbd5cd Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 5 Jul 2023 23:30:34 +0200 Subject: [PATCH 05/51] feat: restrict Download collection to current user --- .../DownloadQueryCollectionExtension.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php diff --git a/api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php b/api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php new file mode 100644 index 000000000..95d8fd234 --- /dev/null +++ b/api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php @@ -0,0 +1,36 @@ +getName() + || !$user = $this->security->getUser() + ) { + return; + } + + $queryBuilder->andWhere(sprintf('%s.user = :user', $queryBuilder->getRootAliases()[0])) + ->setParameter('user', $user); + } +} From 6a982a57622072011bc5e85e6da7f1077d0bd213 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 6 Jul 2023 00:16:27 +0200 Subject: [PATCH 06/51] feat: add ApiFilters --- api/src/Entity/Book.php | 6 ++++++ api/src/Entity/Download.php | 5 +++++ api/src/Entity/Review.php | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 26bf146d7..5c99b7547 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -4,6 +4,9 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -77,6 +80,7 @@ class Book /** * @see https://schema.org/headline */ + #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] #[ApiProperty(types: ['https://schema.org/headline'])] #[Groups(groups: ['Book:read'])] #[ORM\Column] @@ -85,6 +89,7 @@ class Book /** * @see https://schema.org/author */ + #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] #[ApiProperty(types: ['https://schema.org/author'])] #[Groups(groups: ['Book:read'])] #[ORM\Column] @@ -93,6 +98,7 @@ class Book /** * @see https://schema.org/OfferItemCondition */ + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/OfferItemCondition'])] #[Groups(groups: ['Book:read', 'Book:write'])] #[Assert\NotNull] diff --git a/api/src/Entity/Download.php b/api/src/Entity/Download.php index 8c0dba520..cee442e01 100644 --- a/api/src/Entity/Download.php +++ b/api/src/Entity/Download.php @@ -4,6 +4,9 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; @@ -50,6 +53,7 @@ class Download */ #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: false)] + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/agent'])] #[Groups(groups: ['Download:read'])] public ?User $user = null; @@ -59,6 +63,7 @@ class Download */ #[ORM\ManyToOne(targetEntity: Book::class)] #[ORM\JoinColumn(nullable: false)] + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/object'])] #[Groups(groups: ['Download:read', 'Download:write'])] #[Assert\NotNull] diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index 9b3113bed..4251046b5 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -4,6 +4,9 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -88,6 +91,7 @@ class Review */ #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: false)] + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/author'])] #[Groups(groups: ['Review:read'])] public ?User $user = null; @@ -97,6 +101,7 @@ class Review */ #[ORM\ManyToOne(targetEntity: Book::class)] #[ORM\JoinColumn(nullable: false)] + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/itemReviewed'])] #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotNull] @@ -123,6 +128,7 @@ class Review * @see https://schema.org/reviewRating */ #[ORM\Column(type: 'smallint')] + #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/reviewRating'])] #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotNull] From 95dddf5a53d622d79991da77c0ba18e4a534473a Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Sun, 9 Jul 2023 20:49:18 +0200 Subject: [PATCH 07/51] feat: add UserProvider --- api/config/packages/security.yaml | 4 +- api/src/Security/Core/UserProvider.php | 58 +++++++++++++++++++++++++ api/src/Security/OidcTokenGenerator.php | 2 + 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 api/src/Security/Core/UserProvider.php diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml index b9652eb1f..3812b15aa 100644 --- a/api/config/packages/security.yaml +++ b/api/config/packages/security.yaml @@ -6,9 +6,7 @@ security: # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: app_user_provider: - entity: - class: App\Entity\User - property: email + id: 'App\Security\Core\UserProvider' role_hierarchy: ROLE_ADMIN: ROLE_USER firewalls: diff --git a/api/src/Security/Core/UserProvider.php b/api/src/Security/Core/UserProvider.php new file mode 100644 index 000000000..43d554e1e --- /dev/null +++ b/api/src/Security/Core/UserProvider.php @@ -0,0 +1,58 @@ +registry->getManagerForClass($user::class); + if (!$manager) { + throw new UnsupportedUserException(sprintf('User class "%s" not supported.', $user::class)); + } + + $manager->refresh($user); + + return $user; + } + + public function supportsClass(string $class): bool + { + return User::class === $class; + } + + /** + * Create or update User on login. + */ + public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface + { + $user = $this->repository->findOneBy(['email' => $identifier]) ?: new User(); + + if (!isset($attributes['firstName'])) { + throw new UnsupportedUserException('Property "firstName" is missing in token attributes.'); + } + $user->firstName = $attributes['firstName']; + + if (!isset($attributes['lastName'])) { + throw new UnsupportedUserException('Property "lastName" is missing in token attributes.'); + } + $user->lastName = $attributes['lastName']; + + $this->repository->save($user); + + return $user; + } +} diff --git a/api/src/Security/OidcTokenGenerator.php b/api/src/Security/OidcTokenGenerator.php index a2074ed0c..4d0b7eee3 100644 --- a/api/src/Security/OidcTokenGenerator.php +++ b/api/src/Security/OidcTokenGenerator.php @@ -44,6 +44,8 @@ public function generate(array $claims): string 'exp' => $time + 3600, 'iss' => $this->issuer, 'aud' => $this->audience, + 'firstName' => 'John', + 'lastName' => 'DOE', ]; if (empty($claims['iat'])) { $claims['iat'] = $time; From d6dfa46842157a1b910c081eb3c4f183374b7711 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 7 Jul 2023 19:07:30 +0200 Subject: [PATCH 08/51] test: add tests --- .editorconfig | 2 +- api/composer.json | 1 + api/composer.lock | 5 +- api/config/packages/validator.yaml | 2 +- api/src/Entity/Book.php | 62 ++- api/src/Entity/Download.php | 52 +- api/src/Entity/Review.php | 75 ++- api/src/Entity/User.php | 16 +- api/src/Serializer/BookNormalizer.php | 13 +- .../Serializer/IriTransformerNormalizer.php | 61 +++ api/tests/Api/Admin/BookTest.php | 486 ++++++++++++++++++ api/tests/Api/Admin/DownloadTest.php | 77 +++ api/tests/Api/Admin/ReviewTest.php | 333 ++++++++++++ .../Admin/Trait/UsersDataProviderTrait.php | 25 + api/tests/Api/Admin/UserTest.php | 100 ++++ .../Api/Admin/schemas/Book/collection.json | 170 ++++++ api/tests/Api/Admin/schemas/Book/item.json | 73 +++ .../Admin/schemas/Download/collection.json | 230 +++++++++ .../Api/Admin/schemas/Review/collection.json | 254 +++++++++ api/tests/Api/Admin/schemas/Review/item.json | 149 ++++++ api/tests/Api/Admin/schemas/User/item.json | 43 ++ api/tests/Api/BookTest.php | 111 ++++ api/tests/Api/DownloadTest.php | 147 ++++++ api/tests/Api/ReviewTest.php | 427 +++++++++++++++ api/tests/Api/schemas/Book/collection.json | 180 +++++++ api/tests/Api/schemas/Book/item.json | 83 +++ .../Api/schemas/Download/collection.json | 148 ++++++ api/tests/Api/schemas/Download/item.json | 85 +++ api/tests/Api/schemas/Review/collection.json | 173 +++++++ api/tests/Api/schemas/Review/item.json | 110 ++++ 30 files changed, 3629 insertions(+), 64 deletions(-) create mode 100644 api/src/Serializer/IriTransformerNormalizer.php create mode 100644 api/tests/Api/Admin/BookTest.php create mode 100644 api/tests/Api/Admin/DownloadTest.php create mode 100644 api/tests/Api/Admin/ReviewTest.php create mode 100644 api/tests/Api/Admin/Trait/UsersDataProviderTrait.php create mode 100644 api/tests/Api/Admin/UserTest.php create mode 100644 api/tests/Api/Admin/schemas/Book/collection.json create mode 100644 api/tests/Api/Admin/schemas/Book/item.json create mode 100644 api/tests/Api/Admin/schemas/Download/collection.json create mode 100644 api/tests/Api/Admin/schemas/Review/collection.json create mode 100644 api/tests/Api/Admin/schemas/Review/item.json create mode 100644 api/tests/Api/Admin/schemas/User/item.json create mode 100644 api/tests/Api/BookTest.php create mode 100644 api/tests/Api/DownloadTest.php create mode 100644 api/tests/Api/ReviewTest.php create mode 100644 api/tests/Api/schemas/Book/collection.json create mode 100644 api/tests/Api/schemas/Book/item.json create mode 100644 api/tests/Api/schemas/Download/collection.json create mode 100644 api/tests/Api/schemas/Download/item.json create mode 100644 api/tests/Api/schemas/Review/collection.json create mode 100644 api/tests/Api/schemas/Review/item.json diff --git a/.editorconfig b/.editorconfig index b5d6af985..beb930fcd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,7 +21,7 @@ indent_size = 2 [*.json] indent_style = space -indent_size = 2 +indent_size = 4 [*.md] trim_trailing_whitespace = false diff --git a/api/composer.json b/api/composer.json index 7e5438ed5..dd2a408e0 100644 --- a/api/composer.json +++ b/api/composer.json @@ -5,6 +5,7 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "ext-xml": "*", "api-platform/core": "^3.1", "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", diff --git a/api/composer.lock b/api/composer.lock index 80d5ede6a..776156a3a 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "39017a450ba5919f491be4dfe68d28bb", + "content-hash": "48c2ba466ee8f0c60c13a79ba8399a97", "packages": [ { "name": "api-platform/core", @@ -9417,7 +9417,8 @@ "platform": { "php": ">=8.2", "ext-ctype": "*", - "ext-iconv": "*" + "ext-iconv": "*", + "ext-xml": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/api/config/packages/validator.yaml b/api/config/packages/validator.yaml index 1affc688f..5eb5dc92f 100644 --- a/api/config/packages/validator.yaml +++ b/api/config/packages/validator.yaml @@ -5,4 +5,4 @@ framework: # Enables validator auto-mapping support. # For instance, basic validation constraints will be inferred from Doctrine's metadata. auto_mapping: - App\Entity\: [] + # App\Entity\: [] diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 5c99b7547..0c8996f27 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -29,29 +29,42 @@ * @see https://schema.org/Book */ #[ApiResource( - shortName: 'Book', + uriTemplate: '/admin/books{._format}', types: ['https://schema.org/Book', 'https://schema.org/Offer'], operations: [ - new GetCollection(), - new Get(), + new GetCollection( + itemUriTemplate: '/admin/books/{id}{._format}' + ), new Post( - routePrefix: '/admin', - security: 'is_granted("ROLE_ADMIN")', - processor: BookPersistProcessor::class + processor: BookPersistProcessor::class, + itemUriTemplate: '/admin/books/{id}{._format}' + ), + new Get( + uriTemplate: '/admin/books/{id}{._format}' ), new Patch( - routePrefix: '/admin', - security: 'is_granted("ROLE_ADMIN")', - processor: BookPersistProcessor::class + uriTemplate: '/admin/books/{id}{._format}', + processor: BookPersistProcessor::class, + itemUriTemplate: '/admin/books/{id}{._format}' ), new Delete( - routePrefix: '/admin', - security: 'is_granted("ROLE_ADMIN")' + uriTemplate: '/admin/books/{id}{._format}' ), ], - normalizationContext: ['groups' => ['Book:read', 'Enum:read']], + normalizationContext: ['groups' => ['Book:read:admin', 'Enum:read']], denormalizationContext: ['groups' => ['Book:write']], - mercure: true + mercure: true, // todo ensure mercure message is sent to "/books/*" too + security: 'is_granted("ROLE_ADMIN")' +)] +#[ApiResource( + types: ['https://schema.org/Book', 'https://schema.org/Offer'], + operations: [ + new GetCollection( + itemUriTemplate: '/books/{id}{._format}' + ), + new Get(), + ], + normalizationContext: ['groups' => ['Book:read', 'Enum:read']] )] #[ORM\Entity] #[UniqueEntity(fields: ['book'])] @@ -70,8 +83,11 @@ class Book /** * @see https://schema.org/itemOffered */ - #[ApiProperty(types: ['https://schema.org/itemOffered', 'https://purl.org/dc/terms/BibliographicResource'])] - #[Groups(groups: ['Book:read', 'Book:write'])] + #[ApiProperty( + types: ['https://schema.org/itemOffered', 'https://purl.org/dc/terms/BibliographicResource'], + example: 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s' + )] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Book:write'])] #[Assert\NotBlank(allowNull: false)] #[Assert\Url] #[ORM\Column(unique: true)] @@ -82,7 +98,7 @@ class Book */ #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] #[ApiProperty(types: ['https://schema.org/headline'])] - #[Groups(groups: ['Book:read'])] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Download:read', 'Review:read:admin'])] #[ORM\Column] public ?string $title = null; @@ -91,7 +107,7 @@ class Book */ #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] #[ApiProperty(types: ['https://schema.org/author'])] - #[Groups(groups: ['Book:read'])] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Download:read', 'Review:read:admin'])] #[ORM\Column] public ?string $author = null; @@ -99,8 +115,11 @@ class Book * @see https://schema.org/OfferItemCondition */ #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] - #[ApiProperty(types: ['https://schema.org/OfferItemCondition'])] - #[Groups(groups: ['Book:read', 'Book:write'])] + #[ApiProperty( + types: ['https://schema.org/OfferItemCondition'], + example: BookCondition::DamagedCondition->value + )] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Book:write'])] #[Assert\NotNull] #[ORM\Column(name: '`condition`', type: 'string', enumType: BookCondition::class)] public ?BookCondition $condition = null; @@ -110,7 +129,10 @@ class Book * * @see https://schema.org/reviews */ - #[ApiProperty(types: ['https://schema.org/reviews'])] + #[ApiProperty( + types: ['https://schema.org/reviews'], + example: '/books/6acacc80-8321-4d83-9b02-7f2c7bf6eb1d/reviews' + )] #[Groups(groups: ['Book:read'])] public ?string $reviews = null; diff --git a/api/src/Entity/Download.php b/api/src/Entity/Download.php index cee442e01..72b22ae99 100644 --- a/api/src/Entity/Download.php +++ b/api/src/Entity/Download.php @@ -9,8 +9,10 @@ use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; +use App\Serializer\IriTransformerNormalizer; use App\State\Processor\DownloadPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; @@ -25,16 +27,50 @@ */ #[ORM\Entity] #[ApiResource( - shortName: 'Download', types: ['https://schema.org/DownloadAction'], operations: [ - new GetCollection(security: 'is_granted("ROLE_USER")'), - new GetCollection(uriTemplate: '/admin/downloads.{_format}', security: 'is_granted("ROLE_ADMIN")'), - new Post(security: 'is_granted("ROLE_USER")', processor: DownloadPersistProcessor::class), + new GetCollection( + uriTemplate: '/admin/downloads{._format}', + itemUriTemplate: '/admin/downloads/{id}{._format}', + security: 'is_granted("ROLE_ADMIN")', + normalizationContext: [ + 'groups' => ['Download:read', 'Download:read:admin'], + IriTransformerNormalizer::CONTEXT_KEY => [ + 'book' => '/admin/books/{id}{._format}', + 'user' => '/admin/users/{id}{._format}', + ], + ], + ), + new Get( + uriTemplate: '/admin/downloads/{id}{._format}', + security: 'is_granted("ROLE_ADMIN")', + normalizationContext: [ + 'groups' => ['Download:read', 'Download:read:admin'], + IriTransformerNormalizer::CONTEXT_KEY => [ + 'book' => '/admin/books/{id}{._format}', + 'user' => '/admin/users/{id}{._format}', + ], + ], + ), + new GetCollection( + filters: [], // disable filters + itemUriTemplate: '/downloads/{id}{._format}' + ), + new Get(), + new Post( + processor: DownloadPersistProcessor::class, + itemUriTemplate: '/downloads/{id}{._format}' + ), + ], + normalizationContext: [ + 'groups' => ['Download:read'], + IriTransformerNormalizer::CONTEXT_KEY => [ + 'book' => '/books/{id}{._format}', + ], ], - normalizationContext: ['groups' => ['Download:read']], denormalizationContext: ['groups' => ['Download:write']], - mercure: true + mercure: true, + security: 'is_granted("ROLE_USER")' )] class Download { @@ -45,7 +81,7 @@ class Download #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[ApiProperty(types: ['https://schema.org/identifier'])] + #[ApiProperty(identifier: true, types: ['https://schema.org/identifier'])] private ?Uuid $id = null; /** @@ -55,7 +91,7 @@ class Download #[ORM\JoinColumn(nullable: false)] #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/agent'])] - #[Groups(groups: ['Download:read'])] + #[Groups(groups: ['Download:read:admin'])] public ?User $user = null; /** diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index 4251046b5..143f66e1d 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -5,6 +5,7 @@ namespace App\Entity; use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; @@ -15,7 +16,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; -use ApiPlatform\Metadata\Put; +use App\Serializer\IriTransformerNormalizer; use App\State\Processor\ReviewPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; @@ -32,46 +33,80 @@ #[ApiResource( types: ['https://schema.org/Review'], operations: [ - new GetCollection(), - new Get(), - new Put(), - new Patch(), - new Delete(), + new GetCollection( + uriTemplate: '/admin/reviews{._format}', + itemUriTemplate: '/admin/reviews/{id}{._format}' + ), + new Get( + uriTemplate: '/admin/reviews/{id}{._format}' + ), + new Patch( + uriTemplate: '/admin/reviews/{id}{._format}', + itemUriTemplate: '/admin/reviews/{id}{._format}' + ), + new Delete( + uriTemplate: '/admin/reviews/{id}{._format}' + ), + ], + normalizationContext: [ + 'groups' => ['Review:read', 'Review:read:admin'], + IriTransformerNormalizer::CONTEXT_KEY => [ + 'book' => '/admin/books/{id}{._format}', + 'user' => '/admin/users/{id}{._format}', + ], ], - routePrefix: '/admin', - normalizationContext: ['groups' => ['Review:read']], denormalizationContext: ['groups' => ['Review:write']], mercure: true, security: 'is_granted("ROLE_ADMIN")' )] #[ApiResource( - uriTemplate: '/books/{bookId}/reviews.{_format}', types: ['https://schema.org/Review'], + uriTemplate: '/books/{bookId}/reviews{._format}', + uriVariables: [ + 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), + ], operations: [ - new GetCollection(), - new Post(security: 'is_granted("ROLE_USER")', processor: ReviewPersistProcessor::class), + new GetCollection( + filters: [], // disable filters + itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}' + ), + new Get( + uriTemplate: '/books/{bookId}/reviews/{id}{._format}', + uriVariables: [ + 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), + 'id' => new Link(fromClass: Review::class), + ], + ), + new Post( + security: 'is_granted("ROLE_USER")', + processor: ReviewPersistProcessor::class, + itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}' + ), new Patch( - uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', + uriTemplate: '/books/{bookId}/reviews/{id}{._format}', uriVariables: [ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), 'id' => new Link(fromClass: Review::class), ], - security: 'is_granted("ROLE_USER") and user == object.getUser()', - processor: ReviewPersistProcessor::class + itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}', + security: 'is_granted("ROLE_USER") and user == object.user' ), new Delete( - uriTemplate: '/books/{bookId}/reviews/{id}.{_format}', + uriTemplate: '/books/{bookId}/reviews/{id}{._format}', uriVariables: [ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), 'id' => new Link(fromClass: Review::class), ], - security: 'is_granted("ROLE_USER") and user == object.getUser()' + security: 'is_granted("ROLE_USER") and user == object.user' ), ], - uriVariables: [ - 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), + normalizationContext: [ + IriTransformerNormalizer::CONTEXT_KEY => [ + 'book' => '/books/{id}{._format}', + 'user' => '/users/{id}{._format}', + ], + 'groups' => ['Review:read'], ], - normalizationContext: ['groups' => ['Review:read']], denormalizationContext: ['groups' => ['Review:write']] )] class Review @@ -128,7 +163,7 @@ class Review * @see https://schema.org/reviewRating */ #[ORM\Column(type: 'smallint')] - #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] + #[ApiFilter(NumericFilter::class)] #[ApiProperty(types: ['https://schema.org/reviewRating'])] #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotNull] diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 73860c078..68ba8c2da 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -22,10 +22,16 @@ #[ORM\Entity] #[ORM\Table(name: '`user`')] #[ApiResource( - shortName: 'User', types: ['https://schema.org/Person'], operations: [ - new Get(uriTemplate: '/admin/users/{id}.{_format}', security: 'is_granted("ROLE_ADMIN")'), + new Get( + uriTemplate: '/admin/users/{id}{._format}', + security: 'is_granted("ROLE_ADMIN")' + ), + new Get( + uriTemplate: '/users/{id}{._format}', + security: 'is_granted("ROLE_USER") and object.getUserIdentifier() === user.getUserIdentifier()' + ), ], normalizationContext: ['groups' => ['User:read']] )] @@ -46,8 +52,6 @@ class User implements UserInterface * @see https://schema.org/email */ #[ORM\Column(unique: true)] - #[ApiProperty(types: ['https://schema.org/email'])] - #[Groups(groups: ['User:read'])] public ?string $email = null; /** @@ -55,7 +59,7 @@ class User implements UserInterface */ #[ORM\Column] #[ApiProperty(types: ['https://schema.org/givenName'])] - #[Groups(groups: ['User:read'])] + #[Groups(groups: ['User:read', 'Review:read', 'Download:read:admin'])] public ?string $firstName = null; /** @@ -63,7 +67,7 @@ class User implements UserInterface */ #[ORM\Column] #[ApiProperty(types: ['https://schema.org/familyName'])] - #[Groups(groups: ['User:read'])] + #[Groups(groups: ['User:read', 'Review:read', 'Download:read:admin'])] public ?string $lastName = null; #[ORM\Column(type: 'json')] diff --git a/api/src/Serializer/BookNormalizer.php b/api/src/Serializer/BookNormalizer.php index 4677190ae..2cde92545 100644 --- a/api/src/Serializer/BookNormalizer.php +++ b/api/src/Serializer/BookNormalizer.php @@ -23,15 +23,16 @@ public function __construct(private RouterInterface $router) */ public function normalize(mixed $object, string $format = null, array $context = []): array { - return $this->normalizer->normalize($object, $format, $context + [static::class => true]) + [ - 'reviews' => $this->router->generate('_api_/books/{bookId}/reviews.{_format}_get_collection', [ - 'bookId' => $object->getId(), - ]), - ]; + // set "reviews" on the object, and let the serializer decide if it must be exposed or not + $object->reviews = $this->router->generate('_api_/books/{bookId}/reviews{._format}_get_collection', [ + 'bookId' => $object->getId(), + ]); + + return $this->normalizer->normalize($object, $format, $context + [self::class => true]); } public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return $data instanceof Book && !isset($context[static::class]); + return $data instanceof Book && !isset($context[self::class]); } } diff --git a/api/src/Serializer/IriTransformerNormalizer.php b/api/src/Serializer/IriTransformerNormalizer.php new file mode 100644 index 000000000..f8148f43f --- /dev/null +++ b/api/src/Serializer/IriTransformerNormalizer.php @@ -0,0 +1,61 @@ +normalizer->normalize($object, $format, $context + [self::class => true]); + + $value = $context[self::CONTEXT_KEY]; + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $property => $uriTemplate) { + $iri = $this->iriConverter->getIriFromResource( + $object->{$property}, + UrlGeneratorInterface::ABS_PATH, + $this->operationMetadataFactory->create($uriTemplate) + ); + + if (is_string($data[$property])) { + $data[$property] = $iri; + } else { + $data[$property]['@id'] = $iri; + } + } + + return $data; + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_object($data) + && !is_iterable($data) + && isset($context[self::CONTEXT_KEY]) + && ItemNormalizer::FORMAT === $format + && !isset($context[self::class]); + } +} diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php new file mode 100644 index 000000000..2b2bc684d --- /dev/null +++ b/api/tests/Api/Admin/BookTest.php @@ -0,0 +1,486 @@ +client = self::createClient(); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetACollectionOfBooks(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/books', $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + /** + * @dataProvider getUrls + */ + public function testAsAdminUserICanGetACollectionOfBooks(FactoryCollection $factory, string $url, int $hydraTotalItems): void + { + $factory->create(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $response = $this->client->request('GET', $url, ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => $hydraTotalItems, + ]); + self::assertCount(min($hydraTotalItems, 30), $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/collection.json')); + } + + public function getUrls(): iterable + { + yield 'all books' => [ + BookFactory::new()->many(100), + '/admin/books', + 100 + ]; + yield 'books filtered by title' => [ + BookFactory::new()->sequence(function () { + yield ['title' => 'Foundation']; + foreach (range(1, 100) as $i) { + yield []; + } + }), + '/admin/books?title=ounda', + 1 + ]; + yield 'books filtered by author' => [ + BookFactory::new()->sequence(function () { + yield ['author' => 'Isaac Asimov']; + foreach (range(1, 100) as $i) { + yield []; + } + }), + '/admin/books?author=isaac', + 1 + ]; + yield 'books filtered by condition' => [ + BookFactory::new()->sequence(function () { + foreach (range(1, 100) as $i) { + // 33% of books are damaged + yield ['condition' => $i%3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; + } + }), + '/admin/books?condition='.BookCondition::DamagedCondition->value, + 33 + ]; + } + + /** + * @dataProvider getAllUsers + */ + public function testAsAnyUserICannotGetAnInvalidBook(?UserFactory $userFactory): void + { + BookFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/books/invalid', $options); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function getAllUsers(): iterable + { + yield [null]; + yield [UserFactory::new()]; + yield [UserFactory::new(['roles' => ['ROLE_ADMIN']])]; + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetABook(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $book = BookFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/books/'.$book->getId(), $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsAdminUserICanGetABook(): void + { + $book = BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('GET', '/admin/books/'.$book->getId(), ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@id' => '/admin/books/'.$book->getId(), + 'book' => $book->book, + 'condition' => $book->condition->value, + 'title' => $book->title, + 'author' => $book->author, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('POST', '/admin/books', $options + [ + 'json' => [ + 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'condition' => BookCondition::NewCondition->value, + ], + ]); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + /** + * @dataProvider getInvalidData + */ + public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, array $violations): void + { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('POST', '/admin/books', [ + 'auth_bearer' => $token, + 'json' => $data, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => $violations, + ]); + } + + public function getInvalidData(): iterable + { + yield [ + [], + [ + [ + 'propertyPath' => 'book', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'condition', + 'message' => 'This value should not be null.', + ], + ] + ]; + yield [ + [ + 'book' => 'invalid book', + 'condition' => BookCondition::NewCondition->value, + ], + [ + [ + 'propertyPath' => 'book', + 'message' => 'This value is not a valid URL.', + ], + ] + ]; + } + + public function testAsAdminUserICanCreateABook(): void + { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('POST', '/admin/books', [ + 'auth_bearer' => $token, + 'json' => [ + 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'condition' => BookCondition::NewCondition->value, + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_CREATED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'condition' => BookCondition::NewCondition->value, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotUpdateBook(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $book = BookFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('PATCH', '/admin/books/'.$book->getId(), $options + [ + 'json' => [ + 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'condition' => BookCondition::NewCondition->value, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICannotUpdateAnInvalidBook(): void + { + BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('PATCH', '/admin/books/invalid', [ + 'auth_bearer' => $token, + 'json' => [ + 'condition' => BookCondition::DamagedCondition->value, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + /** + * @dataProvider getInvalidData + */ + public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, array $violations): void + { + $this->markTestIncomplete('Invalid identifier value or configuration.'); + BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('PATCH', '/admin/books/invalid', [ + 'auth_bearer' => $token, + 'json' => $data, + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => $violations, + ]); + } + + public function testAsAdminUserICanUpdateABook(): void + { + $book = BookFactory::createOne([ + 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + ]); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('PATCH', '/admin/books/'.$book->getId(), [ + 'auth_bearer' => $token, + 'json' => [ + 'condition' => BookCondition::DamagedCondition->value, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'condition' => BookCondition::DamagedCondition->value, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotDeleteABook(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $book = BookFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('DELETE', '/admin/books/'.$book->getId(), $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICannotDeleteAnInvalidBook(): void + { + BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('DELETE', '/admin/books/invalid', ['auth_bearer' => $token]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAdminUserICanDeleteABook(): void + { + $book = BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $response = $this->client->request('DELETE', '/admin/books/'.$book->getId(), ['auth_bearer' => $token]); + + self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); + self::assertEmpty($response->getContent()); + } +} diff --git a/api/tests/Api/Admin/DownloadTest.php b/api/tests/Api/Admin/DownloadTest.php new file mode 100644 index 000000000..991c82b2a --- /dev/null +++ b/api/tests/Api/Admin/DownloadTest.php @@ -0,0 +1,77 @@ +client = self::createClient(); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetACollectionOfDownloads(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + DownloadFactory::createMany(10, ['user' => UserFactory::createOne()]); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/downloads', $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICanGetACollectionOfDownloads(): void + { + DownloadFactory::createMany(100, ['user' => UserFactory::createOne()]); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $response = $this->client->request('GET', '/admin/downloads', ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => 100, + ]); + self::assertCount(30, $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/collection.json')); + } +} diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php new file mode 100644 index 000000000..8ad52248f --- /dev/null +++ b/api/tests/Api/Admin/ReviewTest.php @@ -0,0 +1,333 @@ +client = self::createClient(); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetACollectionOfReviews(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/reviews', $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + /** + * @dataProvider getAdminUrls + */ + public function testAsAdminUserICanGetACollectionOfReviews(FactoryCollection $factory, string|callable $url, int $hydraTotalItems): void + { + $factory->create(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + if (is_callable($url)) { + $url = $url(); + } + + $response = $this->client->request('GET', $url, ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => $hydraTotalItems, + ]); + self::assertCount(min($hydraTotalItems, 30), $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/collection.json')); + } + + public function getAdminUrls(): iterable + { + yield 'all reviews' => [ + ReviewFactory::new()->many(100), + '/admin/reviews', + 100 + ]; + yield 'reviews filtered by rating' => [ + ReviewFactory::new()->sequence(function () { + foreach (range(1, 100) as $i) { + // 33% of reviews are rated 5 + yield ['rating' => $i%3 ? 3 : 5]; + } + }), + '/admin/reviews?rating=5', + 33 + ]; + yield 'reviews filtered by user' => [ + ReviewFactory::new()->sequence(function () { + $user = UserFactory::createOne(['email' => 'john.doe@example.com']); + yield ['user' => $user]; + foreach (range(1, 10) as $i) { + yield ['user' => UserFactory::createOne()]; + } + }), + static function (): string { + /** @var User[] $users */ + $users = UserFactory::findBy(['email' => 'john.doe@example.com']); + + return '/admin/reviews?user=/admin/users/'.$users[0]->getId(); + }, + 1 + ]; + yield 'reviews filtered by book' => [ + ReviewFactory::new()->sequence(function () { + yield ['book' => BookFactory::createOne(['title' => 'Foundation'])]; + foreach (range(1, 10) as $i) { + yield ['book' => BookFactory::createOne()]; + } + }), + static function (): string { + /** @var Book[] $books */ + $books = BookFactory::findBy(['title' => 'Foundation']); + + return '/admin/reviews?book=/books/'.$books[0]->getId(); + }, + 1 + ]; + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetAReview(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $review = ReviewFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/reviews/'.$review->getId(), $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICannotGetAnInvalidReview(): void + { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('GET', '/admin/reviews/invalid', ['auth_bearer' => $token]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAdminUserICanGetAReview(): void + { + $review = ReviewFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('GET', '/admin/reviews/'.$review->getId(), ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotUpdateAReview(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $review = ReviewFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/reviews/'.$review->getId(), $options + [ + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + ]); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICannotUpdateAnInvalidReview(): void + { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('PATCH', '/admin/reviews/invalid', [ + 'auth_bearer' => $token, + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAdminUserICanUpdateAReview(): void + { + $review = ReviewFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('PATCH', '/admin/reviews/'.$review->getId(), [ + 'auth_bearer' => $token, + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'body' => 'Very good book!', + 'rating' => 5, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotDeleteAReview(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $review = ReviewFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('DELETE', '/admin/reviews/'.$review->getId(), $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICannotDeleteAnInvalidReview(): void + { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('DELETE', '/admin/reviews/invalid', ['auth_bearer' => $token]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAdminUserICanDeleteAReview(): void + { + $review = ReviewFactory::createOne()->disableAutoRefresh(); + $id = $review->getId(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('DELETE', '/admin/reviews/'.$review->getId(), ['auth_bearer' => $token]); + + self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); + self::assertNull(self::getContainer()->get(ReviewRepository::class)->find($id)); + } +} diff --git a/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php b/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php new file mode 100644 index 000000000..ef2e630f6 --- /dev/null +++ b/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php @@ -0,0 +1,25 @@ +client = self::createClient(); + } + + /** + * @dataProvider getNonAdminUsers + */ + public function testAsNonAdminUserICannotGetAUser(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void + { + $user = UserFactory::createOne(); + + $options = []; + if ($userFactory) { + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $userFactory->create()->email, + ]); + $options['auth_bearer'] = $token; + } + + $this->client->request('GET', '/admin/users/'.$user->getId(), $options); + + self::assertResponseStatusCodeSame($expectedCode); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => $hydraDescription, + ]); + } + + public function testAsAdminUserICanGetAUser(): void + { + $user = UserFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOneAdmin()->email, + ]); + + $this->client->request('GET', '/admin/users/'.$user->getId(), ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@id' => '/admin/users/'.$user->getId(), + ]); + // note: email property is never exposed + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/User/item.json')); + } + + public function testAsAUserIAmUpdatedOnLogin(): void + { + $user = UserFactory::createOne([ + 'firstName' => 'John', + 'lastName' => 'DOE', + ])->disableAutoRefresh(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $user->email, + 'firstName' => 'Chuck', + 'lastName' => 'NORRIS', + ]); + + $this->client->request('GET', '/books', ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + $user = self::getContainer()->get(UserRepository::class)->find($user->getId()); + self::assertNotNull($user); + self::assertEquals('Chuck', $user->firstName); + self::assertEquals('NORRIS', $user->lastName); + } +} diff --git a/api/tests/Api/Admin/schemas/Book/collection.json b/api/tests/Api/Admin/schemas/Book/collection.json new file mode 100644 index 000000000..bce4899fe --- /dev/null +++ b/api/tests/Api/Admin/schemas/Book/collection.json @@ -0,0 +1,170 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Book:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books/.+$" + }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + }, + "condition": { + "description": "The condition of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/OfferItemCondition" + }, + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + } + }, + "required": [ + "@type", + "@id", + "book", + "title", + "author", + "condition" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Book:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + }, + "hydra:search": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + }, + "hydra:mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view", + "hydra:search" + ] +} diff --git a/api/tests/Api/Admin/schemas/Book/item.json b/api/tests/Api/Admin/schemas/Book/item.json new file mode 100644 index 000000000..ffb9709ce --- /dev/null +++ b/api/tests/Api/Admin/schemas/Book/item.json @@ -0,0 +1,73 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books/.+$" + }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + }, + "condition": { + "description": "The condition of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/OfferItemCondition" + }, + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + } + }, + "required": [ + "@context", + "@type", + "@id", + "book", + "title", + "author", + "condition" + ] +} diff --git a/api/tests/Api/Admin/schemas/Download/collection.json b/api/tests/Api/Admin/schemas/Download/collection.json new file mode 100644 index 000000000..d75fbf0df --- /dev/null +++ b/api/tests/Api/Admin/schemas/Download/collection.json @@ -0,0 +1,230 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Download:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https:\/\/schema.org\/DownloadAction$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/downloads/.+$" + }, + "book": { + "description": "The object of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/object" + }, + "type": "object", + "$ref": "#\/definitions\/Book:jsonld" + }, + "user": { + "description": "The direct performer or driver of the action (animate or inanimate)", + "externalDocs": { + "url": "https:\/\/schema.org\/agent" + }, + "type": "object", + "$ref": "#\/definitions\/User:jsonld" + }, + "downloadedAt": { + "description": "The date time of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/startTime" + }, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "@id", + "@type", + "book", + "downloadedAt" + ] + }, + "Book:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books/.+$" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "title", + "author" + ] + }, + "User:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/users/.+$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "firstName", + "lastName" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Download$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/downloads$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Download:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + }, + "hydra:search": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + }, + "hydra:mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view", + "hydra:search" + ] +} diff --git a/api/tests/Api/Admin/schemas/Review/collection.json b/api/tests/Api/Admin/schemas/Review/collection.json new file mode 100644 index 000000000..1b44a9e4c --- /dev/null +++ b/api/tests/Api/Admin/schemas/Review/collection.json @@ -0,0 +1,254 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Review:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/reviews/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "enum": [ + "https://schema.org/Review" + ] + }, + "user": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "$ref": "#\/definitions\/User:jsonld" + }, + "book": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "$ref": "#\/definitions\/Book:jsonld" + }, + "publishedAt": { + "description": "The publication date of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/datePublished" + }, + "type": "string", + "format": "date-time" + }, + "body": { + "description": "The body of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewBody" + }, + "type": "string" + }, + "rating": { + "description": "The rating of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewRating" + }, + "type": "number" + }, + "letter": { + "description": "The letter rating of the review", + "deprecated": true, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "user", + "book", + "publishedAt", + "body", + "rating" + ] + }, + "Book:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books/.+$" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "title", + "author" + ] + }, + "User:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/users/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "firstName", + "lastName" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Review$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/reviews$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Review:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + }, + "hydra:search": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + }, + "hydra:mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view", + "hydra:search" + ] +} diff --git a/api/tests/Api/Admin/schemas/Review/item.json b/api/tests/Api/Admin/schemas/Review/item.json new file mode 100644 index 000000000..57e4dabf1 --- /dev/null +++ b/api/tests/Api/Admin/schemas/Review/item.json @@ -0,0 +1,149 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Review$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/reviews/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "enum": [ + "https://schema.org/Review" + ] + }, + "user": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/users/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "firstName", + "lastName" + ] + }, + "book": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/books/.+$" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "title", + "author" + ] + }, + "publishedAt": { + "description": "The publication date of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/datePublished" + }, + "type": "string", + "format": "date-time" + }, + "body": { + "description": "The body of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewBody" + }, + "type": "string" + }, + "rating": { + "description": "The rating of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewRating" + }, + "type": "number" + }, + "letter": { + "description": "The letter rating of the review", + "deprecated": true, + "type": "string" + } + }, + "required": [ + "@context", + "@id", + "@type", + "user", + "book", + "publishedAt", + "body", + "rating" + ] +} diff --git a/api/tests/Api/Admin/schemas/User/item.json b/api/tests/Api/Admin/schemas/User/item.json new file mode 100644 index 000000000..d775791da --- /dev/null +++ b/api/tests/Api/Admin/schemas/User/item.json @@ -0,0 +1,43 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/User$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/admin/users/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@context", + "@id", + "@type", + "firstName", + "lastName" + ] +} diff --git a/api/tests/Api/BookTest.php b/api/tests/Api/BookTest.php new file mode 100644 index 000000000..2892ba343 --- /dev/null +++ b/api/tests/Api/BookTest.php @@ -0,0 +1,111 @@ +client = self::createClient(); + } + + /** + * @dataProvider getUrls + */ + public function testAsAnonymousICanGetACollectionOfBooks(FactoryCollection $factory, string $url, int $hydraTotalItems): void + { + $factory->create(); + + $response = $this->client->request('GET', $url); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => $hydraTotalItems, + ]); + self::assertCount(min($hydraTotalItems, 30), $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/collection.json')); + } + + public function getUrls(): iterable + { + yield 'all books' => [ + BookFactory::new()->many(100), + '/books', + 100 + ]; + yield 'books filtered by title' => [ + BookFactory::new()->sequence(function () { + yield ['title' => 'Foundation']; + foreach (range(1, 100) as $i) { + yield []; + } + }), + '/books?title=ounda', + 1 + ]; + yield 'books filtered by author' => [ + BookFactory::new()->sequence(function () { + yield ['author' => 'Isaac Asimov']; + foreach (range(1, 100) as $i) { + yield []; + } + }), + '/books?author=isaac', + 1 + ]; + yield 'books filtered by condition' => [ + BookFactory::new()->sequence(function () { + foreach (range(1, 100) as $i) { + // 33% of books are damaged + yield ['condition' => $i%3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; + } + }), + '/books?condition='.BookCondition::DamagedCondition->value, + 33 + ]; + } + + public function testAsAnonymousICannotGetAnInvalidBook(): void + { + BookFactory::createOne(); + + $this->client->request('GET', '/books/invalid'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAnonymousICanGetABook(): void + { + $book = BookFactory::createOne(); + + $this->client->request('GET', '/books/'.$book->getId()); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@id' => '/books/'.$book->getId(), + 'book' => $book->book, + 'condition' => $book->condition->value, + 'title' => $book->title, + 'author' => $book->author, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); + } +} diff --git a/api/tests/Api/DownloadTest.php b/api/tests/Api/DownloadTest.php new file mode 100644 index 000000000..f829f4e9a --- /dev/null +++ b/api/tests/Api/DownloadTest.php @@ -0,0 +1,147 @@ +client = self::createClient(); + } + + public function testAsAnonymousICannotGetACollectionOfDownloads(): void + { + DownloadFactory::createMany(100); + + $this->client->request('GET', '/downloads'); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Full authentication is required to access this resource.', + ]); + } + + /** + * Filters are disabled on /downloads. + */ + public function testAsAUserICanGetACollectionOfMyDownloadsWithoutFilters(): void + { + DownloadFactory::createMany(60); + $user = UserFactory::createOne(); + DownloadFactory::createMany(40, ['user' => $user]); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $user->email, + ]); + + $response = $this->client->request('GET', '/downloads', ['auth_bearer' => $token]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => 40, + ]); + self::assertCount(30, $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/collection.json')); + } + + public function testAsAnonymousICannotCreateADownload(): void + { + $book = BookFactory::createOne(); + + $this->client->request('POST', '/downloads', [ + 'json' => [ + 'book' => '/books/'.$book->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Full authentication is required to access this resource.', + ]); + } + + public function testAsAUserICannotCreateADownloadWithInvalidData(): void + { + $this->markTestIncomplete('Identifier "id" could not be transformed.'); + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('POST', '/downloads', [ + 'json' => [ + 'book' => '/books/invalid', + ], + 'auth_bearer' => $token, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => [ + [ + 'propertyPath' => 'book', + 'message' => 'This value is not valid.', + ], + ], + ]); + } + + public function testAsAUserICanCreateADownload(): void + { + $book = BookFactory::createOne(); + $user = UserFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $user->email, + ]); + + $this->client->request('POST', '/downloads', [ + 'json' => [ + 'book' => '/books/'.$book->getId(), + ], + 'auth_bearer' => $token, + ]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'book' => [ + '@id' => '/books/'.$book->getId(), + ], + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/item.json')); + } +} diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php new file mode 100644 index 000000000..5061898b8 --- /dev/null +++ b/api/tests/Api/ReviewTest.php @@ -0,0 +1,427 @@ +client = self::createClient(); + } + + /** + * Filters are disabled on /books/{bookId}/reviews. + * + * @dataProvider getUrls + */ + public function testAsAnonymousICanGetACollectionOfBookReviewsWithoutFilters(FactoryCollection $factory, string|callable $url, int $hydraTotalItems): void + { + $factory->create(); + + if (is_callable($url)) { + $url = $url(); + } + + $response = $this->client->request('GET', $url); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'hydra:totalItems' => $hydraTotalItems, + ]); + self::assertCount(min($hydraTotalItems, 30), $response->toArray()['hydra:member']); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/collection.json')); + } + + public function getUrls(): iterable + { + yield 'all book reviews' => [ + ReviewFactory::new()->sequence(function () { + $book = BookFactory::createOne(['title' => 'Foundation']); + foreach (range(1, 100) as $i) { + yield ['book' => $book]; + } + }), + static function (): string { + /** @var Book[] $books */ + $books = BookFactory::findBy(['title' => 'Foundation']); + + return '/books/'.$books[0]->getId().'/reviews'; + }, + 100 + ]; + yield 'book reviews filtered by rating' => [ + ReviewFactory::new()->sequence(function () { + $book = BookFactory::createOne(['title' => 'Foundation']); + foreach (range(1, 100) as $i) { + // 33% of reviews are rated 5 + yield ['book' => $book, 'rating' => $i%3 ? 3 : 5]; + } + }), + static function (): string { + /** @var Book[] $books */ + $books = BookFactory::findBy(['title' => 'Foundation']); + + return '/books/'.$books[0]->getId().'/reviews?rating=5'; + }, + 100 + ]; + yield 'book reviews filtered by user' => [ + ReviewFactory::new()->sequence(function () { + $book = BookFactory::createOne(['title' => 'Foundation']); + yield ['book' => $book, 'user' => UserFactory::createOne(['email' => 'john.doe@example.com'])]; + foreach (range(1, 99) as $i) { + yield ['book' => $book, 'user' => UserFactory::createOne()]; + } + }), + static function (): string { + /** @var Book[] $books */ + $books = BookFactory::findBy(['title' => 'Foundation']); + /** @var User[] $users */ + $users = UserFactory::findBy(['email' => 'john.doe@example.com']); + + return '/books/'.$books[0]->getId().'/reviews?user=/users/'.$users[0]->getId(); + }, + 100 + ]; + } + + public function testAsAnonymousICannotAddAReviewOnABook(): void + { + $book = BookFactory::createOne(); + + $this->client->request('POST', '/books/'.$book->getId().'/reviews', [ + 'json' => [ + 'book' => '/books/'.$book->getId(), + 'body' => 'Very good book!', + 'rating' => 5, + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Full authentication is required to access this resource.', + ]); + } + + /** + * @dataProvider getInvalidData + */ + public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, array $violations): void + { + $book = BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('POST', '/books/'.$book->getId().'/reviews', [ + 'auth_bearer' => $token, + 'json' => $data, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => $violations, + ]); + } + + public function getInvalidData(): iterable + { + yield [ + [], + [ + [ + 'propertyPath' => 'book', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'body', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'rating', + 'message' => 'This value should not be null.', + ], + ] + ]; +// yield [ +// [ +// 'book' => 'invalid book', +// 'body' => 'Very good book!', +// 'rating' => 5, +// ], +// [ +// [ +// 'propertyPath' => 'book', +// 'message' => 'This value is not a valid URL.', +// ], +// ] +// ]; + } + + public function testAsAUserICanAddAReviewOnABook(): void + { + $book = BookFactory::createOne(); + $user = UserFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $user->email, + ]); + + $this->client->request('POST', '/books/'.$book->getId().'/reviews', [ + 'auth_bearer' => $token, + 'json' => [ + 'book' => '/books/'.$book->getId(), + 'body' => 'Very good book!', + 'rating' => 5, + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_CREATED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'book' => '/books/'.$book->getId(), + 'user' => [ + '@id' => '/users/'.$user->getId(), + ], + 'body' => 'Very good book!', + 'rating' => 5, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + } + + public function testAsAnonymousICannotGetAnInvalidReview(): void + { + $book = BookFactory::createOne(); + + $this->client->request('GET', '/books/'.$book->getId().'/reviews/invalid'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAnonymousICanGetABookReview(): void + { + $review = ReviewFactory::createOne(); + + $this->client->request('GET', '/books/'.$review->book->getId().'/reviews/'.$review->getId()); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + } + + public function testAsAnonymousICannotUpdateABookReview(): void + { + $review = ReviewFactory::createOne(); + + $this->client->request('PATCH', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [ + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Full authentication is required to access this resource.', + ]); + } + + public function testAsAUserICannotUpdateABookReviewOfAnotherUser(): void + { + $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('PATCH', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [ + 'auth_bearer' => $token, + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Access Denied.', + ]); + } + + public function testAsAUserICannotUpdateAnInvalidBookReview(): void + { + $book = BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('PATCH', '/books/'.$book->getId().'/reviews/invalid', [ + 'auth_bearer' => $token, + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAUserICanUpdateMyBookReview(): void + { + $review = ReviewFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $review->user->email, + ]); + + $this->client->request('PATCH', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [ + 'auth_bearer' => $token, + 'json' => [ + 'body' => 'Very good book!', + 'rating' => 5, + ], + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + 'body' => 'Very good book!', + 'rating' => 5, + ]); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + } + + public function testAsAnonymousICannotDeleteABookReview(): void + { + $review = ReviewFactory::createOne(); + + $this->client->request('DELETE', '/books/'.$review->book->getId().'/reviews/'.$review->getId()); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Full authentication is required to access this resource.', + ]); + } + + public function testAsAUserICannotDeleteABookReviewOfAnotherUser(): void + { + $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('DELETE', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [ + 'auth_bearer' => $token, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Access Denied.', + ]); + } + + public function testAsAUserICannotDeleteAnInvalidBookReview(): void + { + $book = BookFactory::createOne(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => UserFactory::createOne()->email, + ]); + + $this->client->request('PATCH', '/books/'.$book->getId().'/reviews/invalid', [ + 'auth_bearer' => $token, + 'headers' => [ + 'Content-Type' => 'application/merge-patch+json', + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testAsAUserICanDeleteMyBookReview(): void + { + $review = ReviewFactory::createOne()->disableAutoRefresh(); + $id = $review->getId(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'sub' => Uuid::v4()->__toString(), + 'email' => $review->user->email, + ]); + + $this->client->request('DELETE', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [ + 'auth_bearer' => $token, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); + self::assertNull(self::getContainer()->get(ReviewRepository::class)->find($id)); + } +} diff --git a/api/tests/Api/schemas/Book/collection.json b/api/tests/Api/schemas/Book/collection.json new file mode 100644 index 000000000..411bdae6e --- /dev/null +++ b/api/tests/Api/schemas/Book/collection.json @@ -0,0 +1,180 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Book:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+$" + }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + }, + "condition": { + "description": "The condition of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/OfferItemCondition" + }, + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + }, + "reviews": { + "description": "The IRI of the book reviews", + "externalDocs": { + "url": "https:\/\/schema.org\/reviews" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+/reviews$" + } + }, + "required": [ + "@type", + "@id", + "book", + "title", + "author", + "condition", + "reviews" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Book:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + }, + "hydra:search": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + }, + "hydra:mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view", + "hydra:search" + ] +} diff --git a/api/tests/Api/schemas/Book/item.json b/api/tests/Api/schemas/Book/item.json new file mode 100644 index 000000000..d4eef56ae --- /dev/null +++ b/api/tests/Api/schemas/Book/item.json @@ -0,0 +1,83 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+$" + }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + }, + "condition": { + "description": "The condition of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/OfferItemCondition" + }, + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + }, + "reviews": { + "description": "The IRI of the book reviews", + "externalDocs": { + "url": "https:\/\/schema.org\/reviews" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+/reviews$" + } + }, + "required": [ + "@context", + "@type", + "@id", + "book", + "title", + "author", + "condition", + "reviews" + ] +} diff --git a/api/tests/Api/schemas/Download/collection.json b/api/tests/Api/schemas/Download/collection.json new file mode 100644 index 000000000..920526af7 --- /dev/null +++ b/api/tests/Api/schemas/Download/collection.json @@ -0,0 +1,148 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Download:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https:\/\/schema.org\/DownloadAction$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/downloads/.+$" + }, + "book": { + "description": "The object of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/object" + }, + "type": "object", + "$ref": "#\/definitions\/Book:jsonld" + }, + "downloadedAt": { + "description": "The date time of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/startTime" + }, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "@id", + "@type", + "book", + "downloadedAt" + ] + }, + "Book:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+$" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "title", + "author" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Download$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/downloads$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Download:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view" + ] +} diff --git a/api/tests/Api/schemas/Download/item.json b/api/tests/Api/schemas/Download/item.json new file mode 100644 index 000000000..dfa38593d --- /dev/null +++ b/api/tests/Api/schemas/Download/item.json @@ -0,0 +1,85 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Download$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/DownloadAction$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/downloads/.+$" + }, + "book": { + "description": "The object of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/object" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "@type": { + "readOnly": true, + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "https://schema.org/Book", + "https://schema.org/Offer" + ] + } + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+$" + }, + "title": { + "description": "The title of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/title" + }, + "type": "string" + }, + "author": { + "description": "The author of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "title", + "author" + ] + }, + "downloadedAt": { + "description": "The date time of the download", + "externalDocs": { + "url": "https:\/\/schema.org\/startTime" + }, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "@context", + "@type", + "@id", + "book", + "downloadedAt" + ] +} diff --git a/api/tests/Api/schemas/Review/collection.json b/api/tests/Api/schemas/Review/collection.json new file mode 100644 index 000000000..268523e6e --- /dev/null +++ b/api/tests/Api/schemas/Review/collection.json @@ -0,0 +1,173 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "Review:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+/reviews/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "enum": [ + "https://schema.org/Review" + ] + }, + "user": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "$ref": "#\/definitions\/User:jsonld" + }, + "book": { + "description": "The IRI of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/itemReviewed" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+$" + }, + "publishedAt": { + "description": "The publication date of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/datePublished" + }, + "type": "string", + "format": "date-time" + }, + "body": { + "description": "The body of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewBody" + }, + "type": "string" + }, + "rating": { + "description": "The rating of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewRating" + }, + "type": "number" + }, + "letter": { + "description": "The letter rating of the review", + "deprecated": true, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "user", + "book", + "publishedAt", + "body", + "rating" + ] + }, + "User:jsonld": { + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/users/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "firstName", + "lastName" + ] + } + }, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Review$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^hydra:Collection$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+/reviews$" + }, + "hydra:member": { + "type": "array", + "items": { + "$ref": "#\/definitions\/Review:jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + } + } + }, + "required": [ + "@context", + "@type", + "@id", + "hydra:member", + "hydra:totalItems", + "hydra:view" + ] +} diff --git a/api/tests/Api/schemas/Review/item.json b/api/tests/Api/schemas/Review/item.json new file mode 100644 index 000000000..00e60d7e1 --- /dev/null +++ b/api/tests/Api/schemas/Review/item.json @@ -0,0 +1,110 @@ +{ + "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "@context": { + "readOnly": true, + "type": "string", + "pattern": "^/contexts/Review$" + }, + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/books/.+/reviews/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "enum": [ + "https://schema.org/Review" + ] + }, + "user": { + "description": "The author of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/author" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string", + "pattern": "^/users/.+$" + }, + "@type": { + "readOnly": true, + "type": "string", + "pattern": "^https://schema.org/Person$" + }, + "firstName": { + "description": "The givenName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/givenName" + }, + "type": "string" + }, + "lastName": { + "description": "The familyName of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/familyName" + }, + "type": "string" + } + }, + "required": [ + "@id", + "@type", + "firstName", + "lastName" + ] + }, + "book": { + "description": "The IRI of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/itemReviewed" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+$" + }, + "publishedAt": { + "description": "The publication date of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/datePublished" + }, + "type": "string", + "format": "date-time" + }, + "body": { + "description": "The body of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewBody" + }, + "type": "string" + }, + "rating": { + "description": "The rating of the review", + "externalDocs": { + "url": "https:\/\/schema.org\/reviewRating" + }, + "type": "number" + }, + "letter": { + "description": "The letter rating of the review", + "deprecated": true, + "type": "string" + } + }, + "required": [ + "@context", + "@id", + "@type", + "user", + "book", + "publishedAt", + "body", + "rating" + ] +} From 7e10c9f268005fc73e0d03b7944097d8c0c04ffa Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 12 Jul 2023 20:10:23 +0200 Subject: [PATCH 09/51] fix: QA --- api/composer.lock | 39 ++++++++-------- .../DataFixtures/Factory/DownloadFactory.php | 29 ++++++------ .../DataFixtures/Factory/ReviewFactory.php | 1 + api/src/Entity/Book.php | 2 +- api/src/Repository/BookRepository.php | 46 +++++++++---------- api/src/Repository/DownloadRepository.php | 46 +++++++++---------- api/src/Repository/ReviewRepository.php | 46 +++++++++---------- api/src/Repository/UserRepository.php | 46 +++++++++---------- api/tests/Api/Admin/BookTest.php | 14 +++--- api/tests/Api/Admin/ReviewTest.php | 10 ++-- .../Admin/Trait/UsersDataProviderTrait.php | 4 +- api/tests/Api/BookTest.php | 10 ++-- api/tests/Api/ReviewTest.php | 36 +++++++-------- 13 files changed, 166 insertions(+), 163 deletions(-) diff --git a/api/composer.lock b/api/composer.lock index 776156a3a..e906f0e2f 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -1914,16 +1914,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.22.0", + "version": "1.22.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c" + "reference": "65c39594fbd8c67abfc68bb323f86447bab79cc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", - "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/65c39594fbd8c67abfc68bb323f86447bab79cc0", + "reference": "65c39594fbd8c67abfc68bb323f86447bab79cc0", "shasum": "" }, "require": { @@ -1955,9 +1955,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.22.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.22.1" }, - "time": "2023-06-01T12:35:21+00:00" + "time": "2023-06-29T20:46:06+00:00" }, { "name": "psr/cache", @@ -8673,16 +8673,16 @@ }, { "name": "symfony/maker-bundle", - "version": "v1.49.0", + "version": "v1.50.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "ce1d424f76bbb377f1956cc7641e8e2eafe81cde" + "reference": "a1733f849b999460c308e66f6392fb09b621fa86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/ce1d424f76bbb377f1956cc7641e8e2eafe81cde", - "reference": "ce1d424f76bbb377f1956cc7641e8e2eafe81cde", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/a1733f849b999460c308e66f6392fb09b621fa86", + "reference": "a1733f849b999460c308e66f6392fb09b621fa86", "shasum": "" }, "require": { @@ -8740,13 +8740,14 @@ "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", "keywords": [ "code generator", + "dev", "generator", "scaffold", "scaffolding" ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.49.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.50.0" }, "funding": [ { @@ -8762,7 +8763,7 @@ "type": "tidelift" } ], - "time": "2023-06-07T13:10:14+00:00" + "time": "2023-07-10T18:21:57+00:00" }, { "name": "symfony/phpunit-bridge", @@ -9321,16 +9322,16 @@ }, { "name": "zenstruck/foundry", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/zenstruck/foundry.git", - "reference": "3c46a2f2aa3ad8a1d74583a28a6be05e8c0a46ee" + "reference": "0b29af2f701da8a6a15c5daa4dbbe636a7720baf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zenstruck/foundry/zipball/3c46a2f2aa3ad8a1d74583a28a6be05e8c0a46ee", - "reference": "3c46a2f2aa3ad8a1d74583a28a6be05e8c0a46ee", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/0b29af2f701da8a6a15c5daa4dbbe636a7720baf", + "reference": "0b29af2f701da8a6a15c5daa4dbbe636a7720baf", "shasum": "" }, "require": { @@ -9356,7 +9357,7 @@ "doctrine/orm": "^2.9", "matthiasnoback/symfony-dependency-injection-test": "^4.1", "symfony/framework-bundle": "^5.4|^6.0", - "symfony/maker-bundle": "^1.30", + "symfony/maker-bundle": "^1.49", "symfony/phpunit-bridge": "^5.4|^6.0", "symfony/translation-contracts": "^2.5|^3.0" }, @@ -9398,7 +9399,7 @@ ], "support": { "issues": "https://github.com/zenstruck/foundry/issues", - "source": "https://github.com/zenstruck/foundry/tree/v1.33.0" + "source": "https://github.com/zenstruck/foundry/tree/v1.34.0" }, "funding": [ { @@ -9406,7 +9407,7 @@ "type": "github" } ], - "time": "2023-05-23T16:21:57+00:00" + "time": "2023-07-12T07:19:34+00:00" } ], "aliases": [], diff --git a/api/src/DataFixtures/Factory/DownloadFactory.php b/api/src/DataFixtures/Factory/DownloadFactory.php index 83d2bf218..26e68853e 100644 --- a/api/src/DataFixtures/Factory/DownloadFactory.php +++ b/api/src/DataFixtures/Factory/DownloadFactory.php @@ -9,26 +9,27 @@ use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\RepositoryProxy; + use function Zenstruck\Foundry\lazy; /** * @extends ModelFactory * - * @method Download|Proxy create(array|callable $attributes = []) - * @method static Download|Proxy createOne(array $attributes = []) - * @method static Download|Proxy find(object|array|mixed $criteria) - * @method static Download|Proxy findOrCreate(array $attributes) - * @method static Download|Proxy first(string $sortedField = 'id') - * @method static Download|Proxy last(string $sortedField = 'id') - * @method static Download|Proxy random(array $attributes = []) - * @method static Download|Proxy randomOrCreate(array $attributes = []) + * @method Download|Proxy create(array|callable $attributes = []) + * @method static Download|Proxy createOne(array $attributes = []) + * @method static Download|Proxy find(object|array|mixed $criteria) + * @method static Download|Proxy findOrCreate(array $attributes) + * @method static Download|Proxy first(string $sortedField = 'id') + * @method static Download|Proxy last(string $sortedField = 'id') + * @method static Download|Proxy random(array $attributes = []) + * @method static Download|Proxy randomOrCreate(array $attributes = []) * @method static EntityRepository|RepositoryProxy repository() - * @method static Download[]|Proxy[] all() - * @method static Download[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Download[]|Proxy[] createSequence(iterable|callable $sequence) - * @method static Download[]|Proxy[] findBy(array $attributes) - * @method static Download[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static Download[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static Download[]|Proxy[] all() + * @method static Download[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Download[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static Download[]|Proxy[] findBy(array $attributes) + * @method static Download[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static Download[]|Proxy[] randomSet(int $number, array $attributes = []) * * @psalm-method Proxy create(array|callable $attributes = []) * @psalm-method static Proxy createOne(array $attributes = []) diff --git a/api/src/DataFixtures/Factory/ReviewFactory.php b/api/src/DataFixtures/Factory/ReviewFactory.php index 72417c95e..ad8a6d90f 100644 --- a/api/src/DataFixtures/Factory/ReviewFactory.php +++ b/api/src/DataFixtures/Factory/ReviewFactory.php @@ -9,6 +9,7 @@ use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\RepositoryProxy; + use function Zenstruck\Foundry\lazy; /** diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 0c8996f27..d8340b664 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -125,7 +125,7 @@ class Book public ?BookCondition $condition = null; /** - * An IRI of reviews + * An IRI of reviews. * * @see https://schema.org/reviews */ diff --git a/api/src/Repository/BookRepository.php b/api/src/Repository/BookRepository.php index da6fe1012..f2ca183fa 100644 --- a/api/src/Repository/BookRepository.php +++ b/api/src/Repository/BookRepository.php @@ -39,28 +39,28 @@ public function remove(Book $entity, bool $flush = false): void } } -// /** -// * @return Book[] Returns an array of Book objects -// */ -// public function findByExampleField($value): array -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->orderBy('b.id', 'ASC') -// ->setMaxResults(10) -// ->getQuery() -// ->getResult() -// ; -// } + // /** + // * @return Book[] Returns an array of Book objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('b.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } -// public function findOneBySomeField($value): ?Book -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->getQuery() -// ->getOneOrNullResult() -// ; -// } + // public function findOneBySomeField($value): ?Book + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } } diff --git a/api/src/Repository/DownloadRepository.php b/api/src/Repository/DownloadRepository.php index cbf503307..b39f3c3c6 100644 --- a/api/src/Repository/DownloadRepository.php +++ b/api/src/Repository/DownloadRepository.php @@ -39,28 +39,28 @@ public function remove(Download $entity, bool $flush = false): void } } -// /** -// * @return Download[] Returns an array of Download objects -// */ -// public function findByExampleField($value): array -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->orderBy('b.id', 'ASC') -// ->setMaxResults(10) -// ->getQuery() -// ->getResult() -// ; -// } + // /** + // * @return Download[] Returns an array of Download objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('b.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } -// public function findOneBySomeField($value): ?Download -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->getQuery() -// ->getOneOrNullResult() -// ; -// } + // public function findOneBySomeField($value): ?Download + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } } diff --git a/api/src/Repository/ReviewRepository.php b/api/src/Repository/ReviewRepository.php index a40f34a56..c8e9da7d2 100644 --- a/api/src/Repository/ReviewRepository.php +++ b/api/src/Repository/ReviewRepository.php @@ -39,28 +39,28 @@ public function remove(Review $entity, bool $flush = false): void } } -// /** -// * @return Review[] Returns an array of Review objects -// */ -// public function findByExampleField($value): array -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->orderBy('b.id', 'ASC') -// ->setMaxResults(10) -// ->getQuery() -// ->getResult() -// ; -// } + // /** + // * @return Review[] Returns an array of Review objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('b.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } -// public function findOneBySomeField($value): ?Review -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->getQuery() -// ->getOneOrNullResult() -// ; -// } + // public function findOneBySomeField($value): ?Review + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } } diff --git a/api/src/Repository/UserRepository.php b/api/src/Repository/UserRepository.php index b03c97dd6..25c999f27 100644 --- a/api/src/Repository/UserRepository.php +++ b/api/src/Repository/UserRepository.php @@ -39,28 +39,28 @@ public function remove(User $entity, bool $flush = false): void } } -// /** -// * @return User[] Returns an array of User objects -// */ -// public function findByExampleField($value): array -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->orderBy('b.id', 'ASC') -// ->setMaxResults(10) -// ->getQuery() -// ->getResult() -// ; -// } + // /** + // * @return User[] Returns an array of User objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('b.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } -// public function findOneBySomeField($value): ?User -// { -// return $this->createQueryBuilder('b') -// ->andWhere('b.exampleField = :val') -// ->setParameter('val', $value) -// ->getQuery() -// ->getOneOrNullResult() -// ; -// } + // public function findOneBySomeField($value): ?User + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } } diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php index 2b2bc684d..f154fef06 100644 --- a/api/tests/Api/Admin/BookTest.php +++ b/api/tests/Api/Admin/BookTest.php @@ -84,7 +84,7 @@ public function getUrls(): iterable yield 'all books' => [ BookFactory::new()->many(100), '/admin/books', - 100 + 100, ]; yield 'books filtered by title' => [ BookFactory::new()->sequence(function () { @@ -94,7 +94,7 @@ public function getUrls(): iterable } }), '/admin/books?title=ounda', - 1 + 1, ]; yield 'books filtered by author' => [ BookFactory::new()->sequence(function () { @@ -104,17 +104,17 @@ public function getUrls(): iterable } }), '/admin/books?author=isaac', - 1 + 1, ]; yield 'books filtered by condition' => [ BookFactory::new()->sequence(function () { foreach (range(1, 100) as $i) { // 33% of books are damaged - yield ['condition' => $i%3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; + yield ['condition' => $i % 3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; } }), '/admin/books?condition='.BookCondition::DamagedCondition->value, - 33 + 33, ]; } @@ -269,7 +269,7 @@ public function getInvalidData(): iterable 'propertyPath' => 'condition', 'message' => 'This value should not be null.', ], - ] + ], ]; yield [ [ @@ -281,7 +281,7 @@ public function getInvalidData(): iterable 'propertyPath' => 'book', 'message' => 'This value is not a valid URL.', ], - ] + ], ]; } diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php index 8ad52248f..36d7bc27c 100644 --- a/api/tests/Api/Admin/ReviewTest.php +++ b/api/tests/Api/Admin/ReviewTest.php @@ -91,17 +91,17 @@ public function getAdminUrls(): iterable yield 'all reviews' => [ ReviewFactory::new()->many(100), '/admin/reviews', - 100 + 100, ]; yield 'reviews filtered by rating' => [ ReviewFactory::new()->sequence(function () { foreach (range(1, 100) as $i) { // 33% of reviews are rated 5 - yield ['rating' => $i%3 ? 3 : 5]; + yield ['rating' => $i % 3 ? 3 : 5]; } }), '/admin/reviews?rating=5', - 33 + 33, ]; yield 'reviews filtered by user' => [ ReviewFactory::new()->sequence(function () { @@ -117,7 +117,7 @@ static function (): string { return '/admin/reviews?user=/admin/users/'.$users[0]->getId(); }, - 1 + 1, ]; yield 'reviews filtered by book' => [ ReviewFactory::new()->sequence(function () { @@ -132,7 +132,7 @@ static function (): string { return '/admin/reviews?book=/books/'.$books[0]->getId(); }, - 1 + 1, ]; } diff --git a/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php b/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php index ef2e630f6..2b69a8119 100644 --- a/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php +++ b/api/tests/Api/Admin/Trait/UsersDataProviderTrait.php @@ -14,12 +14,12 @@ public function getNonAdminUsers(): iterable yield [ Response::HTTP_UNAUTHORIZED, 'Full authentication is required to access this resource.', - null + null, ]; yield [ Response::HTTP_FORBIDDEN, 'Access Denied.', - UserFactory::new() + UserFactory::new(), ]; } } diff --git a/api/tests/Api/BookTest.php b/api/tests/Api/BookTest.php index 2892ba343..bdcb18d43 100644 --- a/api/tests/Api/BookTest.php +++ b/api/tests/Api/BookTest.php @@ -48,7 +48,7 @@ public function getUrls(): iterable yield 'all books' => [ BookFactory::new()->many(100), '/books', - 100 + 100, ]; yield 'books filtered by title' => [ BookFactory::new()->sequence(function () { @@ -58,7 +58,7 @@ public function getUrls(): iterable } }), '/books?title=ounda', - 1 + 1, ]; yield 'books filtered by author' => [ BookFactory::new()->sequence(function () { @@ -68,17 +68,17 @@ public function getUrls(): iterable } }), '/books?author=isaac', - 1 + 1, ]; yield 'books filtered by condition' => [ BookFactory::new()->sequence(function () { foreach (range(1, 100) as $i) { // 33% of books are damaged - yield ['condition' => $i%3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; + yield ['condition' => $i % 3 ? BookCondition::NewCondition : BookCondition::DamagedCondition]; } }), '/books?condition='.BookCondition::DamagedCondition->value, - 33 + 33, ]; } diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php index 5061898b8..8dd9caa8e 100644 --- a/api/tests/Api/ReviewTest.php +++ b/api/tests/Api/ReviewTest.php @@ -70,14 +70,14 @@ static function (): string { return '/books/'.$books[0]->getId().'/reviews'; }, - 100 + 100, ]; yield 'book reviews filtered by rating' => [ ReviewFactory::new()->sequence(function () { $book = BookFactory::createOne(['title' => 'Foundation']); foreach (range(1, 100) as $i) { // 33% of reviews are rated 5 - yield ['book' => $book, 'rating' => $i%3 ? 3 : 5]; + yield ['book' => $book, 'rating' => $i % 3 ? 3 : 5]; } }), static function (): string { @@ -86,7 +86,7 @@ static function (): string { return '/books/'.$books[0]->getId().'/reviews?rating=5'; }, - 100 + 100, ]; yield 'book reviews filtered by user' => [ ReviewFactory::new()->sequence(function () { @@ -104,7 +104,7 @@ static function (): string { return '/books/'.$books[0]->getId().'/reviews?user=/users/'.$users[0]->getId(); }, - 100 + 100, ]; } @@ -174,21 +174,21 @@ public function getInvalidData(): iterable 'propertyPath' => 'rating', 'message' => 'This value should not be null.', ], - ] + ], ]; -// yield [ -// [ -// 'book' => 'invalid book', -// 'body' => 'Very good book!', -// 'rating' => 5, -// ], -// [ -// [ -// 'propertyPath' => 'book', -// 'message' => 'This value is not a valid URL.', -// ], -// ] -// ]; + // yield [ + // [ + // 'book' => 'invalid book', + // 'body' => 'Very good book!', + // 'rating' => 5, + // ], + // [ + // [ + // 'propertyPath' => 'book', + // 'message' => 'This value is not a valid URL.', + // ], + // ] + // ]; } public function testAsAUserICanAddAReviewOnABook(): void From 6eb651235c479dd90b87945391901a89b27a23fe Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:18:19 +0200 Subject: [PATCH 10/51] docs: update README.md --- README.md | 52 +++++++++++++++++++++------------------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f691c39c6..b579d8ccc 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ -

API Platform

- -This application is a demonstration for the [API Platform Framework](https://api-platform.com). +

+ + API Platform +
API Platform - Demo +
+

+ +This application is a demonstration for the [API Platform Framework](https://api-platform.com/). Try it online at . [![GitHub Actions](https://github.com/api-platform/demo/workflows/CI/badge.svg)](https://github.com/api-platform/demo/actions?workflow=CI) @@ -8,7 +13,7 @@ Try it online at . ## Install -[Read the official "Getting Started" guide](https://api-platform.com/docs/distribution). +[Read the official "Getting Started" guide](https://api-platform.com/docs/distribution/). $ git clone https://github.com/api-platform/demo.git $ cd demo @@ -26,39 +31,24 @@ All entities used in this project are thoroughly tested. Each test class extends the `ApiTestCase`, which contains specific API assertions. It will make your tests much more straightforward than using the standard `WebTestCase` provided by Symfony. -* [Tests documentation](https://api-platform.com/docs/core/testing/) +* [Documentation](https://api-platform.com/docs/core/testing/) * [Code in api/tests/](api/tests) -### Custom Data Provider - -This example shows how to expose a CSV file as a standard API Platform endpoint. -It also shows how to make this endpoint paginated with an extension. - -* [Data providers documentation](https://api-platform.com/docs/core/data-providers/) -* [Code in api/src/DataProvider](api/src/DataProvider) - -### Overriding the OpenAPI Specification - -This example shows how to document an API endpoint that isn't handled by API Platform. -This "legacy" endpoint is listed and testable like the other ones thanks to the -Swagger interface. - -* [Overriding the OpenAPI Specification documentation](https://api-platform.com/docs/core/openapi/#overriding-the-openapi-specification) -* [Code in api/src/OpenApi/OpenApiFactory.php](api/src/OpenApi/OpenApiFactory.php) +### Extensions -### Custom Doctrine ORM Filter +The `Download` collection is restricted to the current user, except for admin users. The Doctrine Query is overridden +using a Doctrine Extension. -This example shows how to implement a custom API filter using Doctrine. It allows -to filter archived items with a GET parameter. There are three modes: +* [Documentation](https://api-platform.com/docs/core/extensions/) +* [Code in api/src/Doctrine/Orm/Extension](api/src/Doctrine/Orm/Extension) -* no filter (default) -* only archived item (`?archived=1`) -* exclude archived items (`?archived=0`) +### State Processors -Links: +The `Download` and `Review` entities require dynamic properties set before save: a date of creation, and a link to the +current user. This is done using State Processors. -* [Creating Custom Doctrine ORM Filters documentation](https://api-platform.com/docs/core/filters/#creating-custom-doctrine-orm-filters) -* [Code in api/src/Filter](api/src/Filter) +* [Documentation](https://api-platform.com/docs/core/state-processors/) +* [Code in api/src/State/Processor](api/src/State/Processor) ## Contributing @@ -66,4 +56,4 @@ Links: ## Credits -Created by [Kévin Dunglas](https://dunglas.fr). Commercial support available at [Les-Tilleuls.coop](https://les-tilleuls.coop). +Created by [Kévin Dunglas](https://dunglas.fr/). Commercial support available at [Les-Tilleuls.coop](https://les-tilleuls.coop/). From 5373dc27864892e10835418e27926569ee017a8f Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 28 Jul 2023 15:30:52 +0200 Subject: [PATCH 11/51] chore: refacto --- .github/workflows/build.yml | 63 +- .github/workflows/ci.yml | 134 +- .github/workflows/cleanup.yml | 18 +- .github/workflows/deploy.yml | 27 +- .github/workflows/security.yml | 24 +- api/.env | 3 - api/Dockerfile | 104 +- api/composer.json | 8 +- api/composer.lock | 48 +- api/config/packages/api_platform.yaml | 22 + api/config/services.yaml | 3 - api/docker/php/docker-entrypoint.sh | 5 - ...07185549.php => Version20230731095341.php} | 36 +- api/src/DataFixtures/AppFixtures.php | 8 +- api/src/DataFixtures/Factory/BookFactory.php | 2 +- .../DataFixtures/Factory/BookmarkFactory.php | 86 ++ .../DataFixtures/Factory/DownloadFactory.php | 86 -- api/src/DataFixtures/Factory/UserFactory.php | 2 + .../DataFixtures/Story/DefaultBookStory.php | 54 +- .../Story/DefaultBookmarkStory.php | 23 + .../Story/DefaultDownloadStory.php | 16 - .../Story/DefaultReviewsStory.php | 10 +- .../DataFixtures/Story/DefaultUsersStory.php | 10 +- ...p => BookmarkQueryCollectionExtension.php} | 10 +- api/src/Entity/Book.php | 56 +- api/src/Entity/{Download.php => Bookmark.php} | 70 +- api/src/Entity/Parchment.php | 12 +- api/src/Entity/Review.php | 56 +- api/src/Entity/User.php | 42 +- .../Exception/InvalidBnfResponseException.php | 9 - ...dRepository.php => BookmarkRepository.php} | 24 +- api/src/Repository/ReviewRepository.php | 11 + api/src/Security/Core/UserProvider.php | 22 +- api/src/Security/OidcTokenGenerator.php | 10 +- api/src/Serializer/BookNormalizer.php | 19 +- .../Serializer/IriTransformerNormalizer.php | 13 +- .../State/Processor/BookPersistProcessor.php | 42 +- ...essor.php => BookmarkPersistProcessor.php} | 16 +- api/tests/Api/Admin/BookTest.php | 32 +- api/tests/Api/Admin/DownloadTest.php | 77 - api/tests/Api/Admin/ReviewTest.php | 21 +- api/tests/Api/Admin/UserTest.php | 13 +- .../Api/Admin/schemas/Review/collection.json | 17 +- api/tests/Api/Admin/schemas/Review/item.json | 17 +- api/tests/Api/Admin/schemas/User/item.json | 17 +- api/tests/Api/BookTest.php | 8 + .../{DownloadTest.php => BookmarkTest.php} | 44 +- api/tests/Api/ReviewTest.php | 33 +- api/tests/Api/schemas/Book/collection.json | 7 + api/tests/Api/schemas/Book/item.json | 7 + .../Bookmark}/collection.json | 97 +- .../schemas/{Download => Bookmark}/item.json | 55 +- .../Api/schemas/Download/collection.json | 148 -- api/tests/Api/schemas/Review/collection.json | 17 +- api/tests/Api/schemas/Review/item.json | 17 +- docker-compose.override.yml | 24 +- docker-compose.prod.yml | 16 +- docker-compose.yml | 23 +- helm/api-platform/Chart.lock | 8 +- helm/api-platform/README.md | 2 +- .../keycloak/config/realm-demo.json | 20 +- helm/api-platform/templates/configmap.yaml | 1 - helm/api-platform/templates/cronjob.yaml | 5 - helm/api-platform/templates/deployment.yaml | 92 -- helm/api-platform/templates/fixtures-job.yaml | 5 - helm/api-platform/values.yaml | 1 - pwa/Dockerfile | 54 +- pwa/components/admin/Admin.tsx | 95 +- pwa/components/admin/AppBar.tsx | 49 +- pwa/components/admin/DocContext.ts | 4 +- pwa/components/admin/HydraLogo.tsx | 16 +- pwa/components/admin/Logo.tsx | 14 +- pwa/components/admin/Menu.tsx | 11 + pwa/components/admin/book/List.tsx | 63 + pwa/components/admin/review/List.tsx | 20 + pwa/components/admin/themes.ts | 70 - pwa/components/book/Filters.tsx | 128 ++ pwa/components/book/Form.tsx | 345 ----- pwa/components/book/Item.tsx | 66 + pwa/components/book/List.tsx | 178 +-- pwa/components/book/PageList.tsx | 41 - pwa/components/book/Show.tsx | 306 ++-- pwa/components/bookmark/List.tsx | 45 + pwa/components/common/Error.tsx | 26 + pwa/components/common/Header.tsx | 65 +- pwa/components/common/Layout.tsx | 17 +- pwa/components/common/Loading.tsx | 15 + pwa/components/common/Pagination.tsx | 75 +- pwa/components/common/ReferenceLinks.tsx | 34 - pwa/components/common/header.module.css | 52 - pwa/components/review/Form.tsx | 377 ++--- pwa/components/review/Item.tsx | 102 ++ pwa/components/review/List.tsx | 188 ++- pwa/components/review/PageList.tsx | 42 - pwa/components/review/Show.tsx | 112 -- pwa/next.config.js | 14 +- pwa/package.json | 60 +- pwa/pages/_app.tsx | 23 +- pwa/pages/admin/index.tsx | 14 +- pwa/pages/api/auth/[...nextauth].tsx | 15 +- pwa/pages/bookmarks/index.tsx | 45 + pwa/pages/books/[id]/[slug]/index.tsx | 26 + pwa/pages/books/[id]/edit.tsx | 85 -- pwa/pages/books/[id]/index.tsx | 86 -- pwa/pages/books/create.tsx | 17 - pwa/pages/books/index.tsx | 65 +- pwa/pages/books/page/[page].tsx | 57 - pwa/pages/index.tsx | 59 +- pwa/pages/reviews/[id]/edit.tsx | 85 -- pwa/pages/reviews/[id]/index.tsx | 88 -- pwa/pages/reviews/create.tsx | 17 - pwa/pages/reviews/index.tsx | 30 - pwa/pages/reviews/page/[page].tsx | 57 - pwa/pnpm-lock.yaml | 1236 ++++++++--------- pwa/tailwind.config.js | 5 +- pwa/tsconfig.json | 8 +- pwa/types/Book.ts | 19 +- pwa/types/Bookmark.ts | 14 + pwa/types/OpenLibrary/Book.ts | 12 + pwa/types/OpenLibrary/Description.ts | 6 + pwa/types/OpenLibrary/Item.ts | 6 + pwa/types/OpenLibrary/Work.ts | 9 + pwa/types/Review.ts | 12 +- pwa/types/Thumbnails.ts | 7 + pwa/types/User.ts | 15 + pwa/utils/book.ts | 84 ++ pwa/utils/dataAccess.ts | 35 +- pwa/utils/mercure.ts | 12 +- 128 files changed, 3017 insertions(+), 3944 deletions(-) rename api/migrations/{Version20230707185549.php => Version20230731095341.php} (60%) create mode 100644 api/src/DataFixtures/Factory/BookmarkFactory.php delete mode 100644 api/src/DataFixtures/Factory/DownloadFactory.php create mode 100644 api/src/DataFixtures/Story/DefaultBookmarkStory.php delete mode 100644 api/src/DataFixtures/Story/DefaultDownloadStory.php rename api/src/Doctrine/Orm/Extension/{DownloadQueryCollectionExtension.php => BookmarkQueryCollectionExtension.php} (78%) rename api/src/Entity/{Download.php => Bookmark.php} (53%) delete mode 100644 api/src/Exception/InvalidBnfResponseException.php rename api/src/Repository/{DownloadRepository.php => BookmarkRepository.php} (66%) rename api/src/State/Processor/{DownloadPersistProcessor.php => BookmarkPersistProcessor.php} (63%) delete mode 100644 api/tests/Api/Admin/DownloadTest.php rename api/tests/Api/{DownloadTest.php => BookmarkTest.php} (75%) rename api/tests/Api/{Admin/schemas/Download => schemas/Bookmark}/collection.json (74%) rename api/tests/Api/schemas/{Download => Bookmark}/item.json (52%) delete mode 100644 api/tests/Api/schemas/Download/collection.json create mode 100644 pwa/components/admin/Menu.tsx create mode 100644 pwa/components/admin/book/List.tsx create mode 100644 pwa/components/admin/review/List.tsx delete mode 100644 pwa/components/admin/themes.ts create mode 100644 pwa/components/book/Filters.tsx delete mode 100644 pwa/components/book/Form.tsx create mode 100644 pwa/components/book/Item.tsx delete mode 100644 pwa/components/book/PageList.tsx create mode 100644 pwa/components/bookmark/List.tsx create mode 100644 pwa/components/common/Error.tsx create mode 100644 pwa/components/common/Loading.tsx delete mode 100644 pwa/components/common/ReferenceLinks.tsx delete mode 100644 pwa/components/common/header.module.css create mode 100644 pwa/components/review/Item.tsx delete mode 100644 pwa/components/review/PageList.tsx delete mode 100644 pwa/components/review/Show.tsx create mode 100644 pwa/pages/bookmarks/index.tsx create mode 100644 pwa/pages/books/[id]/[slug]/index.tsx delete mode 100644 pwa/pages/books/[id]/edit.tsx delete mode 100644 pwa/pages/books/[id]/index.tsx delete mode 100644 pwa/pages/books/create.tsx delete mode 100644 pwa/pages/books/page/[page].tsx delete mode 100644 pwa/pages/reviews/[id]/edit.tsx delete mode 100644 pwa/pages/reviews/[id]/index.tsx delete mode 100644 pwa/pages/reviews/create.tsx delete mode 100644 pwa/pages/reviews/index.tsx delete mode 100644 pwa/pages/reviews/page/[page].tsx create mode 100644 pwa/types/Bookmark.ts create mode 100644 pwa/types/OpenLibrary/Book.ts create mode 100644 pwa/types/OpenLibrary/Description.ts create mode 100644 pwa/types/OpenLibrary/Item.ts create mode 100644 pwa/types/OpenLibrary/Work.ts create mode 100644 pwa/types/Thumbnails.ts create mode 100644 pwa/types/User.ts create mode 100644 pwa/utils/book.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b8e520e2..09ee5ca2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,31 +40,38 @@ jobs: contents: 'read' id-token: 'write' steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.gke-credentials }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.gke-project }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud --quiet auth configure-docker gcloud container clusters get-credentials ${{ inputs.gke-cluster }} --zone ${{ inputs.gke-zone }} - - name: Docker metadata + - + name: Docker metadata id: docker-metadata uses: docker/metadata-action@v4 with: images: eu.gcr.io/${{ secrets.gke-project }}/php tags: ${{ inputs.tags }} - - name: Build and push + - + name: Build and push uses: docker/build-push-action@v4 with: context: ./api @@ -85,31 +92,38 @@ jobs: contents: 'read' id-token: 'write' steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.gke-credentials }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.gke-project }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud --quiet auth configure-docker gcloud container clusters get-credentials ${{ inputs.gke-cluster }} --zone ${{ inputs.gke-zone }} - - name: Docker metadata + - + name: Docker metadata id: docker-metadata uses: docker/metadata-action@v4 with: images: eu.gcr.io/${{ secrets.gke-project }}/caddy tags: ${{ inputs.tags }} - - name: Build and push + - + name: Build and push uses: docker/build-push-action@v4 with: context: ./api @@ -130,31 +144,38 @@ jobs: contents: 'read' id-token: 'write' steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.gke-credentials }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.gke-project }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud --quiet auth configure-docker gcloud container clusters get-credentials ${{ inputs.gke-cluster }} --zone ${{ inputs.gke-zone }} - - name: Docker metadata + - + name: Docker metadata id: docker-metadata uses: docker/metadata-action@v4 with: images: eu.gcr.io/${{ secrets.gke-project }}/pwa tags: ${{ inputs.tags }} - - name: Build and push + - + name: Build and push uses: docker/build-push-action@v4 with: context: ./pwa diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 312804c65..b0cac931a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,39 +23,30 @@ jobs: CADDY_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/caddy:latest PWA_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/pwa:latest steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 - # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 - if: github.repository == 'api-platform/demo' + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Build Docker images + uses: docker/bake-action@v3 with: - python-version: 3.9.15 - - name: Auth gcloud - if: github.repository == 'api-platform/demo' - uses: google-github-actions/auth@v1 - with: - credentials_json: ${{ secrets.GKE_SA_KEY }} - - name: Setup gcloud - if: github.repository == 'api-platform/demo' - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{ secrets.GKE_PROJECT }} - - name: Configure gcloud - if: github.repository == 'api-platform/demo' - run: | - gcloud --quiet auth configure-docker - gcloud container clusters get-credentials api-platform-demo --zone europe-west1-c - - name: Pull cache images - if: github.repository == 'api-platform/demo' - run: | - docker pull $PHP_DOCKER_IMAGE || true - docker pull $CADDY_DOCKER_IMAGE || true - docker pull $PWA_DOCKER_IMAGE || true - - name: Pull images - run: docker compose pull --ignore-pull-failures || true - - name: Start services - run: docker compose up --build -d - - name: Wait for services + pull: true + load: true + files: | + docker-compose.yml + docker-compose.override.yml + set: | + *.cache-from=type=gha,scope=${{github.ref}} + *.cache-from=type=gha,scope=refs/heads/main + *.cache-to=type=gha,scope=${{github.ref}},mode=max + - + name: Start services + run: docker compose up --wait --no-build + - + name: Wait for services run: | while status="$(docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" "$(docker compose ps -q php)")"; do case $status in @@ -69,41 +60,64 @@ jobs: esac done exit 1 - - name: Check HTTP reachability + - + name: Check HTTP reachability run: curl -v -o /dev/null http://localhost - - name: Check API reachability + - + name: Check API reachability run: curl -vk -o /dev/null https://localhost - - name: Check PWA reachability + - + name: Check PWA reachability run: "curl -vk -o /dev/null -H 'Accept: text/html' https://localhost" - - name: Create test database + - + name: Create test database run: | docker compose exec -T php bin/console -e test doctrine:database:create docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction - - name: PHPUnit + - + name: PHPUnit run: docker compose exec -T php bin/phpunit - # temporarily disable e2e, waiting for project refacto -# - name: Install pnpm -# uses: pnpm/action-setup@v2 -# with: -# version: 8.6.2 -# - name: Cache playwright binaries -# uses: actions/cache@v3 -# with: -# path: ~/.cache/ms-playwright -# key: ${{ runner.os }}-playwright -# - name: Install Playwright dependencies -# working-directory: pwa -# run: pnpm playwright install -# - name: Run Playwright -# working-directory: pwa -# # use 1 worker to prevent conflict between write and read scenarios -# run: pnpm exec playwright test --workers=1 -# - uses: actions/upload-artifact@v3 -# if: failure() -# with: -# name: playwright-screenshots -# path: pwa/test-results - - name: Doctrine Schema Validator + - + name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8.6.2 + - + name: Cache playwright binaries + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright + - + name: Install Playwright dependencies + working-directory: pwa + run: pnpm playwright install + - + name: Run Playwright + working-directory: pwa + # use 1 worker to prevent conflict between write and read scenarios + run: pnpm exec playwright test --workers=1 + - + uses: actions/upload-artifact@v3 + if: failure() + with: + name: playwright-screenshots + path: pwa/test-results + - + name: Doctrine Schema Validator run: docker compose exec -T php bin/console doctrine:schema:validate - - name: Psalm + - + name: Psalm run: docker compose exec -T php vendor/bin/psalm + lint: + name: Docker Lint + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Lint Dockerfiles + uses: hadolint/hadolint-action@v3.1.0 + with: + recursive: true diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 5197a2b12..b37132e7e 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -18,25 +18,31 @@ jobs: id-token: 'write' steps: # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.GKE_SA_KEY }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.GKE_PROJECT }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud components install gke-gcloud-auth-plugin gcloud --quiet auth configure-docker gcloud container clusters get-credentials api-platform-demo --zone europe-west1-c - - name: Check for existing namespace + - + name: Check for existing namespace id: k8s-namespace run: echo "namespace=$(kubectl get namespace pr-${{ github.event.number }} | tr -d '\n' 2> /dev/null)" >> $GITHUB_OUTPUT - - name: Uninstall release + - + name: Uninstall release if: steps.k8s-namespace.outputs.namespace != '' run: kubectl delete namespace pr-${{ github.event.number }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e1fa4ca4..f63990661 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -65,35 +65,43 @@ jobs: contents: 'read' id-token: 'write' steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.gke-credentials }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.gke-project }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud components install gke-gcloud-auth-plugin gcloud --quiet auth configure-docker gcloud container clusters get-credentials ${{ inputs.gke-cluster }} --zone ${{ inputs.gke-zone }} # https://github.com/helm/helm/issues/8036 - - name: Build helm dependencies + - + name: Build helm dependencies run: | helm repo add bitnami https://charts.bitnami.com/bitnami/ helm repo add stable https://charts.helm.sh/stable/ helm dependency build ./helm/api-platform - - name: Lint Helm + - + name: Lint Helm run: helm lint ./helm/api-platform/ # Release name MUST start with a letter - - name: Deploy + - + name: Deploy run: | set -o pipefail helm upgrade ${{ inputs.release }} ./helm/api-platform \ @@ -131,6 +139,7 @@ jobs: --set=mercure.extraDirectives="demo \ cors_origins ${{ join(fromJSON(inputs.cors), ' ') }}" \ | sed --unbuffered '/USER-SUPPLIED VALUES/,$d' - - name: Debug kube events + - + name: Debug kube events if: failure() run: kubectl get events --namespace=${{ inputs.namespace }} --sort-by .metadata.creationTimestamp diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8f2721677..6841b2982 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -29,34 +29,42 @@ jobs: contents: 'read' id-token: 'write' steps: - - name: Checkout + - + name: Checkout uses: actions/checkout@v3 # gcloud does not work with Python 3.10 because collections.Mappings was removed in Python 3.10. - - uses: actions/setup-python@v4 + - + uses: actions/setup-python@v4 with: python-version: 3.9.15 - - name: Auth gcloud + - + name: Auth gcloud uses: google-github-actions/auth@v1 with: credentials_json: ${{ secrets.GKE_SA_KEY }} - - name: Setup gcloud + - + name: Setup gcloud uses: google-github-actions/setup-gcloud@v1 with: project_id: ${{ secrets.gke-project }} - - name: Configure gcloud + - + name: Configure gcloud run: | gcloud --quiet auth configure-docker gcloud container clusters get-credentials api-platform-demo --zone europe-west1-c - - name: Pull Docker Image + - + name: Pull Docker Image run: docker pull eu.gcr.io/${{ secrets.GKE_PROJECT }}/${{ matrix.image }}:latest - - name: Cache Trivy + - + name: Cache Trivy uses: actions/cache@v3 with: path: .trivy key: ${{ runner.os }}-trivy-${{ github.run_id }} restore-keys: | ${{ runner.os }}-trivy- - - name: Run Trivy Vulnerability Scanner + - + name: Run Trivy Vulnerability Scanner uses: aquasecurity/trivy-action@master with: image-ref: 'eu.gcr.io/${{ secrets.GKE_PROJECT }}/${{ matrix.image }}:latest' diff --git a/api/.env b/api/.env index b278e4d6e..23a3542f1 100644 --- a/api/.env +++ b/api/.env @@ -14,9 +14,6 @@ # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration -API_ENTRYPOINT_SCHEME=https -API_ENTRYPOINT_HOST=localhost - # API Platform distribution TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 TRUSTED_HOSTS=^(localhost|caddy)$ diff --git a/api/Dockerfile b/api/Dockerfile index c7f671963..d036635e9 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,17 +1,27 @@ #syntax=docker/dockerfile:1.4 + # Adapted from https://github.com/dunglas/symfony-docker -# Prod image -FROM php:8.2-fpm-alpine AS app_php -ENV APP_ENV=prod +# Versions +FROM php:8.2-fpm-alpine AS php_upstream +FROM mlocati/php-extension-installer:2 AS php_extension_installer_upstream +FROM composer/composer:2-bin AS composer_upstream +FROM caddy:2-alpine AS caddy_upstream -WORKDIR /srv/app -# php extensions installer: https://github.com/mlocati/docker-php-extension-installer -COPY --from=mlocati/php-extension-installer --link /usr/bin/install-php-extensions /usr/local/bin/ +# The different stages of this Dockerfile are meant to be built into separate images +# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage +# https://docs.docker.com/compose/compose-file/#target + + +# Base PHP image +FROM php_upstream AS php_base + +WORKDIR /srv/app # persistent / runtime deps +# hadolint ignore=DL3018 RUN apk add --no-cache \ acl \ fcgi \ @@ -20,12 +30,15 @@ RUN apk add --no-cache \ git \ ; +# php extensions installer: https://github.com/mlocati/docker-php-extension-installer +COPY --from=php_extension_installer_upstream --link /usr/bin/install-php-extensions /usr/local/bin/ + RUN set -eux; \ install-php-extensions \ - intl \ - zip \ - apcu \ + apcu \ + intl \ opcache \ + zip \ ; ###> recipes ### @@ -35,9 +48,7 @@ RUN set -eux; \ ###< doctrine/doctrine-bundle ### ###< recipes ### -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" COPY --link docker/php/conf.d/app.ini $PHP_INI_DIR/conf.d/ -COPY --link docker/php/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/ COPY --link docker/php/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf RUN mkdir -p /var/run/php @@ -57,16 +68,39 @@ CMD ["php-fpm"] ENV COMPOSER_ALLOW_SUPERUSER=1 ENV PATH="${PATH}:/root/.composer/vendor/bin" -COPY --from=composer/composer:2-bin --link /composer /usr/bin/composer +COPY --from=composer_upstream --link /composer /usr/bin/composer + + +# Dev PHP image +FROM php_base AS php_dev + +ENV APP_ENV=dev XDEBUG_MODE=off +VOLUME /srv/app/var/ + +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + +RUN set -eux; \ + install-php-extensions \ + xdebug \ + ; + +COPY --link docker/php/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/ + +# Prod PHP image +FROM php_base AS php_prod + +ENV APP_ENV=prod + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +COPY --link docker/php/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/ # prevent the reinstallation of vendors at every changes in the source code -COPY composer.* symfony.* ./ +COPY --link composer.* symfony.* ./ RUN set -eux; \ - composer install --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress; \ - composer clear-cache + composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress # copy sources -COPY --link . . +COPY --link . ./ RUN rm -Rf docker/ RUN set -eux; \ @@ -74,38 +108,22 @@ RUN set -eux; \ composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-env prod; \ composer run-script --no-dev post-install-cmd; \ - chmod +x bin/console; sync - -# Dev image -FROM app_php AS app_php_dev + chmod +x bin/console; sync; -ENV APP_ENV=dev XDEBUG_MODE=off -VOLUME /srv/app/var/ - -RUN rm $PHP_INI_DIR/conf.d/app.prod.ini; \ - mv "$PHP_INI_DIR/php.ini" "$PHP_INI_DIR/php.ini-production"; \ - mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" - -COPY --link docker/php/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/ -RUN set -eux; \ - install-php-extensions xdebug +# Base Caddy image +FROM caddy_upstream AS caddy_base -RUN rm -f .env.local.php +ARG TARGETARCH -# Build Caddy with the Mercure and Vulcain modules -# Temporary fix for https://github.com/dunglas/mercure/issues/770 -FROM caddy:2.7-builder-alpine AS app_caddy_builder +WORKDIR /srv/app -RUN xcaddy build v2.6.4 \ - --with github.com/dunglas/mercure/caddy \ - --with github.com/dunglas/vulcain/caddy +# Download Caddy compiled with the Mercure and Vulcain modules +ADD --chmod=500 https://caddyserver.com/api/download?os=linux&arch=$TARGETARCH&p=github.com/dunglas/mercure/caddy&p=github.com/dunglas/vulcain/caddy /usr/bin/caddy -# Caddy image -FROM caddy:2-alpine AS app_caddy +COPY --link docker/caddy/Caddyfile /etc/caddy/Caddyfile -WORKDIR /srv/app +# Prod Caddy image +FROM caddy_base AS caddy_prod -COPY --from=app_caddy_builder --link /usr/bin/caddy /usr/bin/caddy -COPY --from=app_php --link /srv/app/public public/ -COPY --link docker/caddy/Caddyfile /etc/caddy/Caddyfile +COPY --from=php_prod --link /srv/app/public public/ diff --git a/api/composer.json b/api/composer.json index dd2a408e0..6946b8ee2 100644 --- a/api/composer.json +++ b/api/composer.json @@ -1,12 +1,18 @@ { "type": "project", "license": "MIT", + "repositories": [ + { + "type": "vcs", + "url": "git@github.com:vincentchalamon/core.git" + } + ], "require": { "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", "ext-xml": "*", - "api-platform/core": "^3.1", + "api-platform/core": "dev-demo", "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.12", diff --git a/api/composer.lock b/api/composer.lock index e906f0e2f..cac0e877d 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48c2ba466ee8f0c60c13a79ba8399a97", + "content-hash": "556e2634ee5ee54f81424ff814ccaa8f", "packages": [ { "name": "api-platform/core", - "version": "v3.1.12", + "version": "dev-demo", "source": { "type": "git", - "url": "https://github.com/api-platform/core.git", - "reference": "1fe505a9d8fd235a8d7e4aa0f245f382f65578f8" + "url": "https://github.com/vincentchalamon/core.git", + "reference": "2b27c9c704edce96d52acbca0ce6dfc85554ea05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/1fe505a9d8fd235a8d7e4aa0f245f382f65578f8", - "reference": "1fe505a9d8fd235a8d7e4aa0f245f382f65578f8", + "url": "https://api.github.com/repos/vincentchalamon/core/zipball/2b27c9c704edce96d52acbca0ce6dfc85554ea05", + "reference": "2b27c9c704edce96d52acbca0ce6dfc85554ea05", "shasum": "" }, "require": { @@ -74,7 +74,7 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^3.7 || ^4.0", "ramsey/uuid-doctrine": "^1.4", - "soyuka/contexts": "^3.3.6", + "soyuka/contexts": "v3.3.9", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.1", "symfony/browser-kit": "^6.1", @@ -140,7 +140,12 @@ "ApiPlatform\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "ApiPlatform\\Tests\\": "tests/", + "App\\": "tests/Fixtures/app/var/tmp/src/" + } + }, "license": [ "MIT" ], @@ -154,27 +159,26 @@ "description": "Build a fully-featured hypermedia or GraphQL API in minutes!", "homepage": "https://api-platform.com", "keywords": [ + "API", + "GraphQL", + "HAL", "Hydra", "JSON-LD", - "api", - "graphql", - "hal", - "jsonapi", - "openapi", - "rest", - "swagger" + "JSONAPI", + "OpenAPI", + "REST", + "Swagger" ], "support": { - "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v3.1.12" + "source": "https://github.com/vincentchalamon/core/tree/demo" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/api-platform/core", - "type": "tidelift" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/packagist/api-platform/core" } ], - "time": "2023-05-24T19:23:57+00:00" + "time": "2023-07-20T14:37:44+00:00" }, { "name": "brick/math", @@ -9412,7 +9416,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "api-platform/core": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 95b3f35f6..5fe081e2d 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -27,3 +27,25 @@ api_platform: authorizationUrl: '%env(OIDC_SERVER_URL)%/protocol/openid-connect/auth' scopes: openid: (required) Indicates that the application intends to use OIDC to verify the user's identity + +services: + app.filter.review.admin.search: + class: 'ApiPlatform\Doctrine\Orm\Filter\SearchFilter' + arguments: + $managerRegistry: '@doctrine' + $iriConverter: '@api_platform.iri_converter' + $propertyAccessor: '@property_accessor' + $logger: '@logger' + $properties: { user: 'exact', book: 'exact' } ] + $identifiersExtractor: '@api_platform.api.identifiers_extractor' + $nameConverter: '@?api_platform.name_converter' + tags: [ 'api_platform.filter' ] + + app.filter.review.admin.numeric: + class: 'ApiPlatform\Doctrine\Orm\Filter\NumericFilter' + arguments: + $managerRegistry: '@doctrine' + $logger: '@logger' + $properties: { rating: ~ } ] + $nameConverter: '@?api_platform.name_converter' + tags: [ 'api_platform.filter' ] diff --git a/api/config/services.yaml b/api/config/services.yaml index f61bd904a..2d6a76f94 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -4,9 +4,6 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: - # required for consumer - router.request_context.scheme: '%env(API_ENTRYPOINT_SCHEME)%' - router.request_context.host: '%env(API_ENTRYPOINT_HOST)%' services: # default configuration for services in *this* file diff --git a/api/docker/php/docker-entrypoint.sh b/api/docker/php/docker-entrypoint.sh index f84e3d34f..4ece74a93 100755 --- a/api/docker/php/docker-entrypoint.sh +++ b/api/docker/php/docker-entrypoint.sh @@ -39,11 +39,6 @@ if [ "$1" = 'php-fpm' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then php bin/console doctrine:migrations:migrate --no-interaction fi - - if [ "$APP_ENV" != 'prod' ]; then - echo "Load fixtures" - bin/console doctrine:fixtures:load --no-interaction - fi fi fi diff --git a/api/migrations/Version20230707185549.php b/api/migrations/Version20230731095341.php similarity index 60% rename from api/migrations/Version20230707185549.php rename to api/migrations/Version20230731095341.php index bd0518951..7c688a0ac 100644 --- a/api/migrations/Version20230707185549.php +++ b/api/migrations/Version20230731095341.php @@ -10,40 +10,42 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20230707185549 extends AbstractMigration +final class Version20230731095341 extends AbstractMigration { public function getDescription(): string { - return 'Create entities tables.'; + return ''; } public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE TABLE book (id UUID NOT NULL, book VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL, "condition" VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE book (id UUID NOT NULL, book VARCHAR(255) NOT NULL, title TEXT NOT NULL, author VARCHAR(255) DEFAULT NULL, "condition" VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331CBE5A331 ON book (book)'); $this->addSql('COMMENT ON COLUMN book.id IS \'(DC2Type:uuid)\''); - $this->addSql('CREATE TABLE download (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, downloaded_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE INDEX IDX_781A8270A76ED395 ON download (user_id)'); - $this->addSql('CREATE INDEX IDX_781A827016A2B381 ON download (book_id)'); - $this->addSql('COMMENT ON COLUMN download.id IS \'(DC2Type:uuid)\''); - $this->addSql('COMMENT ON COLUMN download.user_id IS \'(DC2Type:uuid)\''); - $this->addSql('COMMENT ON COLUMN download.book_id IS \'(DC2Type:uuid)\''); - $this->addSql('COMMENT ON COLUMN download.downloaded_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE bookmark (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, bookmarked_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D16A2B381 ON bookmark (book_id)'); + $this->addSql('COMMENT ON COLUMN bookmark.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN bookmark.user_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN bookmark.book_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN bookmark.bookmarked_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE parchment (id UUID NOT NULL, title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN parchment.id IS \'(DC2Type:uuid)\''); - $this->addSql('CREATE TABLE review (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, published_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, body VARCHAR(255) NOT NULL, rating SMALLINT NOT NULL, letter VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE review (id UUID NOT NULL, user_id UUID NOT NULL, book_id UUID NOT NULL, published_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, body TEXT NOT NULL, rating SMALLINT NOT NULL, letter VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_794381C6A76ED395 ON review (user_id)'); $this->addSql('CREATE INDEX IDX_794381C616A2B381 ON review (book_id)'); $this->addSql('COMMENT ON COLUMN review.id IS \'(DC2Type:uuid)\''); $this->addSql('COMMENT ON COLUMN review.user_id IS \'(DC2Type:uuid)\''); $this->addSql('COMMENT ON COLUMN review.book_id IS \'(DC2Type:uuid)\''); $this->addSql('COMMENT ON COLUMN review.published_at IS \'(DC2Type:datetime_immutable)\''); - $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, sub UUID NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649580282DC ON "user" (sub)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); $this->addSql('COMMENT ON COLUMN "user".id IS \'(DC2Type:uuid)\''); - $this->addSql('ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE download ADD CONSTRAINT FK_781A827016A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('COMMENT ON COLUMN "user".sub IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D16A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C616A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } @@ -52,12 +54,12 @@ public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); - $this->addSql('ALTER TABLE download DROP CONSTRAINT FK_781A8270A76ED395'); - $this->addSql('ALTER TABLE download DROP CONSTRAINT FK_781A827016A2B381'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D16A2B381'); $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C6A76ED395'); $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C616A2B381'); $this->addSql('DROP TABLE book'); - $this->addSql('DROP TABLE download'); + $this->addSql('DROP TABLE bookmark'); $this->addSql('DROP TABLE parchment'); $this->addSql('DROP TABLE review'); $this->addSql('DROP TABLE "user"'); diff --git a/api/src/DataFixtures/AppFixtures.php b/api/src/DataFixtures/AppFixtures.php index 864073ae8..1cbc40f7e 100644 --- a/api/src/DataFixtures/AppFixtures.php +++ b/api/src/DataFixtures/AppFixtures.php @@ -5,19 +5,19 @@ namespace App\DataFixtures; use App\DataFixtures\Story\DefaultBookStory; -use App\DataFixtures\Story\DefaultDownloadStory; +use App\DataFixtures\Story\DefaultBookmarkStory; use App\DataFixtures\Story\DefaultReviewsStory; use App\DataFixtures\Story\DefaultUsersStory; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -class AppFixtures extends Fixture +final class AppFixtures extends Fixture { public function load(ObjectManager $manager): void { - DefaultUsersStory::load(); DefaultBookStory::load(); + DefaultUsersStory::load(); DefaultReviewsStory::load(); - DefaultDownloadStory::load(); + DefaultBookmarkStory::load(); } } diff --git a/api/src/DataFixtures/Factory/BookFactory.php b/api/src/DataFixtures/Factory/BookFactory.php index 7bae89ea2..460224c71 100644 --- a/api/src/DataFixtures/Factory/BookFactory.php +++ b/api/src/DataFixtures/Factory/BookFactory.php @@ -62,7 +62,7 @@ public function __construct() protected function getDefaults(): array { return [ - 'book' => self::faker()->unique()->url(), + 'book' => 'https://openlibrary.org/books/OL'.self::faker()->unique()->randomNumber(8, true).'M.json', 'title' => self::faker()->text(), 'author' => self::faker()->name(), 'condition' => self::faker()->randomElement(BookCondition::getCases()), diff --git a/api/src/DataFixtures/Factory/BookmarkFactory.php b/api/src/DataFixtures/Factory/BookmarkFactory.php new file mode 100644 index 000000000..1bb6d15b3 --- /dev/null +++ b/api/src/DataFixtures/Factory/BookmarkFactory.php @@ -0,0 +1,86 @@ + + * + * @method Bookmark|Proxy create(array|callable $attributes = []) + * @method static Bookmark|Proxy createOne(array $attributes = []) + * @method static Bookmark|Proxy find(object|array|mixed $criteria) + * @method static Bookmark|Proxy findOrCreate(array $attributes) + * @method static Bookmark|Proxy first(string $sortedField = 'id') + * @method static Bookmark|Proxy last(string $sortedField = 'id') + * @method static Bookmark|Proxy random(array $attributes = []) + * @method static Bookmark|Proxy randomOrCreate(array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method static Bookmark[]|Proxy[] all() + * @method static Bookmark[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Bookmark[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static Bookmark[]|Proxy[] findBy(array $attributes) + * @method static Bookmark[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static Bookmark[]|Proxy[] randomSet(int $number, array $attributes = []) + * + * @psalm-method Proxy create(array|callable $attributes = []) + * @psalm-method static Proxy createOne(array $attributes = []) + * @psalm-method static Proxy find(object|array|mixed $criteria) + * @psalm-method static Proxy findOrCreate(array $attributes) + * @psalm-method static Proxy first(string $sortedField = 'id') + * @psalm-method static Proxy last(string $sortedField = 'id') + * @psalm-method static Proxy random(array $attributes = []) + * @psalm-method static Proxy randomOrCreate(array $attributes = []) + * @psalm-method static RepositoryProxy repository() + * @psalm-method static list> all() + * @psalm-method static list> createMany(int $number, array|callable $attributes = []) + * @psalm-method static list> createSequence(iterable|callable $sequence) + * @psalm-method static list> findBy(array $attributes) + * @psalm-method static list> randomRange(int $min, int $max, array $attributes = []) + * @psalm-method static list> randomSet(int $number, array $attributes = []) + */ +final class BookmarkFactory extends ModelFactory +{ + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + */ + public function __construct() + { + parent::__construct(); + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + */ + protected function getDefaults(): array + { + return [ + 'user' => lazy(fn () => UserFactory::randomOrCreate()), + 'book' => lazy(fn () => BookFactory::randomOrCreate()), + 'bookmarkedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): self + { + return $this + // ->afterInstantiate(function(Bookmark $bookmark): void {}) + ; + } + + protected static function getClass(): string + { + return Bookmark::class; + } +} diff --git a/api/src/DataFixtures/Factory/DownloadFactory.php b/api/src/DataFixtures/Factory/DownloadFactory.php deleted file mode 100644 index 26e68853e..000000000 --- a/api/src/DataFixtures/Factory/DownloadFactory.php +++ /dev/null @@ -1,86 +0,0 @@ - - * - * @method Download|Proxy create(array|callable $attributes = []) - * @method static Download|Proxy createOne(array $attributes = []) - * @method static Download|Proxy find(object|array|mixed $criteria) - * @method static Download|Proxy findOrCreate(array $attributes) - * @method static Download|Proxy first(string $sortedField = 'id') - * @method static Download|Proxy last(string $sortedField = 'id') - * @method static Download|Proxy random(array $attributes = []) - * @method static Download|Proxy randomOrCreate(array $attributes = []) - * @method static EntityRepository|RepositoryProxy repository() - * @method static Download[]|Proxy[] all() - * @method static Download[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Download[]|Proxy[] createSequence(iterable|callable $sequence) - * @method static Download[]|Proxy[] findBy(array $attributes) - * @method static Download[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static Download[]|Proxy[] randomSet(int $number, array $attributes = []) - * - * @psalm-method Proxy create(array|callable $attributes = []) - * @psalm-method static Proxy createOne(array $attributes = []) - * @psalm-method static Proxy find(object|array|mixed $criteria) - * @psalm-method static Proxy findOrCreate(array $attributes) - * @psalm-method static Proxy first(string $sortedField = 'id') - * @psalm-method static Proxy last(string $sortedField = 'id') - * @psalm-method static Proxy random(array $attributes = []) - * @psalm-method static Proxy randomOrCreate(array $attributes = []) - * @psalm-method static RepositoryProxy repository() - * @psalm-method static list> all() - * @psalm-method static list> createMany(int $number, array|callable $attributes = []) - * @psalm-method static list> createSequence(iterable|callable $sequence) - * @psalm-method static list> findBy(array $attributes) - * @psalm-method static list> randomRange(int $min, int $max, array $attributes = []) - * @psalm-method static list> randomSet(int $number, array $attributes = []) - */ -final class DownloadFactory extends ModelFactory -{ - /** - * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services - */ - public function __construct() - { - parent::__construct(); - } - - /** - * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories - */ - protected function getDefaults(): array - { - return [ - 'user' => lazy(fn () => UserFactory::randomOrCreate()), - 'book' => lazy(fn () => BookFactory::randomOrCreate()), - 'downloadedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), - ]; - } - - /** - * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization - */ - protected function initialize(): self - { - return $this - // ->afterInstantiate(function(Download $download): void {}) - ; - } - - protected static function getClass(): string - { - return Download::class; - } -} diff --git a/api/src/DataFixtures/Factory/UserFactory.php b/api/src/DataFixtures/Factory/UserFactory.php index 00108cf97..b0d0836a7 100644 --- a/api/src/DataFixtures/Factory/UserFactory.php +++ b/api/src/DataFixtures/Factory/UserFactory.php @@ -6,6 +6,7 @@ use App\Entity\User; use Doctrine\ORM\EntityRepository; +use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\RepositoryProxy; @@ -66,6 +67,7 @@ public static function createOneAdmin(array $attributes = []): User|Proxy protected function getDefaults(): array { return [ + 'sub' => Uuid::v7(), 'email' => self::faker()->unique()->email(), 'firstName' => self::faker()->firstName(), 'lastName' => self::faker()->lastName(), diff --git a/api/src/DataFixtures/Story/DefaultBookStory.php b/api/src/DataFixtures/Story/DefaultBookStory.php index 5c6f49606..3979ad779 100644 --- a/api/src/DataFixtures/Story/DefaultBookStory.php +++ b/api/src/DataFixtures/Story/DefaultBookStory.php @@ -5,12 +5,64 @@ namespace App\DataFixtures\Story; use App\DataFixtures\Factory\BookFactory; +use App\Enum\BookCondition; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; use Zenstruck\Foundry\Story; final class DefaultBookStory extends Story { + public function __construct( + private readonly DecoderInterface $decoder, + private readonly HttpClientInterface $client, + ) { + } + public function build(): void { - BookFactory::createMany(100); + BookFactory::createOne([ + 'condition' => BookCondition::NewCondition, + 'book' => 'https://openlibrary.org/books/OL26210211M.json', + 'title' => 'Fondation', + 'author' => 'Isaac Asimov', + ]); + + $offset = 0; + $limit = 99; + + while ($offset < $limit) { + /* @see https://openlibrary.org/dev/docs/restful_api */ + $uri = 'https://openlibrary.org/query?type=/type/edition&languages=/languages/eng&subjects=Science%20Fiction&authors=&covers=&title=&description=&publish_date&offset='.$offset; + $books = $this->getData($uri); + foreach ($books as $book) { + $datum = [ + 'condition' => BookCondition::NewCondition, + 'book' => 'https://openlibrary.org'.$book['key'].'.json', + 'title' => $book['title'], + ]; + + if (isset($book['authors'][0]['key'])) { + $author = $this->getData('https://openlibrary.org'.$book['authors'][0]['key']); + if (isset($author['name'])) { + $datum['author'] = $author['name']; + } + } + + BookFactory::createOne($datum); + if (++$offset === $limit) { + break 2; + } + } + } + } + + private function getData(string $uri): array + { + return $this->decoder->decode($this->client->request(Request::METHOD_GET, $uri, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ])->getContent(), 'json'); } } diff --git a/api/src/DataFixtures/Story/DefaultBookmarkStory.php b/api/src/DataFixtures/Story/DefaultBookmarkStory.php new file mode 100644 index 000000000..110a43d6a --- /dev/null +++ b/api/src/DataFixtures/Story/DefaultBookmarkStory.php @@ -0,0 +1,23 @@ + BookFactory::find(['book' => 'https://openlibrary.org/books/OL26210211M.json']), + 'user' => UserFactory::find(['email' => 'john.doe@example.com']), + ]); + + BookmarkFactory::createMany(99); + } +} diff --git a/api/src/DataFixtures/Story/DefaultDownloadStory.php b/api/src/DataFixtures/Story/DefaultDownloadStory.php deleted file mode 100644 index b9134a753..000000000 --- a/api/src/DataFixtures/Story/DefaultDownloadStory.php +++ /dev/null @@ -1,16 +0,0 @@ - BookFactory::find(['book' => 'https://openlibrary.org/books/OL26210211M.json']), + 'user' => UserFactory::find(['email' => 'john.doe@example.com']), + 'rating' => 5, + ]); + + ReviewFactory::createMany(99); } } diff --git a/api/src/DataFixtures/Story/DefaultUsersStory.php b/api/src/DataFixtures/Story/DefaultUsersStory.php index 8b4b38428..e6b841b11 100644 --- a/api/src/DataFixtures/Story/DefaultUsersStory.php +++ b/api/src/DataFixtures/Story/DefaultUsersStory.php @@ -11,12 +11,18 @@ final class DefaultUsersStory extends Story { public function build(): void { - UserFactory::createMany(10); UserFactory::createOne([ - 'email' => 'admin@example.com', + 'email' => 'chuck.norris@example.com', 'firstName' => 'Chuck', 'lastName' => 'Norris', 'roles' => ['ROLE_ADMIN'], ]); + UserFactory::createOne([ + 'email' => 'john.doe@example.com', + 'firstName' => 'John', + 'lastName' => 'Doe', + 'roles' => ['ROLE_USER'], + ]); + UserFactory::createMany(10); } } diff --git a/api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php similarity index 78% rename from api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php rename to api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php index 95d8fd234..d09c47c62 100644 --- a/api/src/Doctrine/Orm/Extension/DownloadQueryCollectionExtension.php +++ b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php @@ -7,14 +7,14 @@ use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; -use App\Entity\Download; +use App\Entity\Bookmark; use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\SecurityBundle\Security; /** - * Restrict Download collection to current user. + * Restrict Bookmark collection to current user. */ -final readonly class DownloadQueryCollectionExtension implements QueryCollectionExtensionInterface +final readonly class BookmarkQueryCollectionExtension implements QueryCollectionExtensionInterface { public function __construct(private Security $security) { @@ -23,8 +23,8 @@ public function __construct(private Security $security) public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void { if ( - Download::class !== $resourceClass - || '_api_/downloads{._format}_get_collection' !== $operation->getName() + Bookmark::class !== $resourceClass + || '_api_/bookmarks{._format}_get_collection' !== $operation->getName() || !$user = $this->security->getUser() ) { return; diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index d8340b664..7d3d98a0e 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -15,7 +15,9 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Enum\BookCondition; +use App\Repository\BookRepository; use App\State\Processor\BookPersistProcessor; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -44,8 +46,7 @@ ), new Patch( uriTemplate: '/admin/books/{id}{._format}', - processor: BookPersistProcessor::class, - itemUriTemplate: '/admin/books/{id}{._format}' + processor: BookPersistProcessor::class ), new Delete( uriTemplate: '/admin/books/{id}{._format}' @@ -66,7 +67,7 @@ ], normalizationContext: ['groups' => ['Book:read', 'Enum:read']] )] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: BookRepository::class)] #[UniqueEntity(fields: ['book'])] class Book { @@ -74,10 +75,10 @@ class Book * @see https://schema.org/identifier */ #[ApiProperty(identifier: true, types: ['https://schema.org/identifier'])] - #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\Id] private ?Uuid $id = null; /** @@ -85,30 +86,37 @@ class Book */ #[ApiProperty( types: ['https://schema.org/itemOffered', 'https://purl.org/dc/terms/BibliographicResource'], - example: 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s' + example: 'https://openlibrary.org/books/OL26210211M.json' )] - #[Groups(groups: ['Book:read', 'Book:read:admin', 'Book:write'])] #[Assert\NotBlank(allowNull: false)] - #[Assert\Url] + #[Assert\Url(protocols: ['https'])] + #[Assert\Regex(pattern: '/^https:\/\/openlibrary.org\/books\/OL\d{8}M\.json$/')] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Book:write'])] #[ORM\Column(unique: true)] public ?string $book = null; /** - * @see https://schema.org/headline + * @see https://schema.org/name */ #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] - #[ApiProperty(types: ['https://schema.org/headline'])] - #[Groups(groups: ['Book:read', 'Book:read:admin', 'Download:read', 'Review:read:admin'])] - #[ORM\Column] + #[ApiProperty( + types: ['https://schema.org/name'], + example: 'Fondation' + )] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Review:read:admin'])] + #[ORM\Column(type: Types::TEXT)] public ?string $title = null; /** * @see https://schema.org/author */ #[ApiFilter(SearchFilter::class, strategy: 'i'.SearchFilterInterface::STRATEGY_PARTIAL)] - #[ApiProperty(types: ['https://schema.org/author'])] - #[Groups(groups: ['Book:read', 'Book:read:admin', 'Download:read', 'Review:read:admin'])] - #[ORM\Column] + #[ApiProperty( + types: ['https://schema.org/author'], + example: 'Isaac Asimov' + )] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Review:read:admin'])] + #[ORM\Column(nullable: true)] public ?string $author = null; /** @@ -117,10 +125,10 @@ class Book #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty( types: ['https://schema.org/OfferItemCondition'], - example: BookCondition::DamagedCondition->value + example: BookCondition::NewCondition->value )] - #[Groups(groups: ['Book:read', 'Book:read:admin', 'Book:write'])] #[Assert\NotNull] + #[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Book:write'])] #[ORM\Column(name: '`condition`', type: 'string', enumType: BookCondition::class)] public ?BookCondition $condition = null; @@ -133,9 +141,21 @@ class Book types: ['https://schema.org/reviews'], example: '/books/6acacc80-8321-4d83-9b02-7f2c7bf6eb1d/reviews' )] - #[Groups(groups: ['Book:read'])] + #[Groups(groups: ['Book:read', 'Bookmark:read'])] public ?string $reviews = null; + /** + * The overall rating, based on a collection of reviews or ratings, of the item. + * + * @see https://schema.org/aggregateRating + */ + #[ApiProperty( + types: ['https://schema.org/aggregateRating'], + example: 1 + )] + #[Groups(groups: ['Book:read', 'Bookmark:read'])] + public ?int $rating = null; + public function getId(): ?Uuid { return $this->id; diff --git a/api/src/Entity/Download.php b/api/src/Entity/Bookmark.php similarity index 53% rename from api/src/Entity/Download.php rename to api/src/Entity/Bookmark.php index 72b22ae99..17e751aac 100644 --- a/api/src/Entity/Download.php +++ b/api/src/Entity/Bookmark.php @@ -12,8 +12,9 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; +use App\Repository\BookmarkRepository; use App\Serializer\IriTransformerNormalizer; -use App\State\Processor\DownloadPersistProcessor; +use App\State\Processor\BookmarkPersistProcessor; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; @@ -21,97 +22,68 @@ use Symfony\Component\Validator\Constraints as Assert; /** - * The act of downloading an object. + * An agent bookmarks/flags/labels/tags/marks an object. * - * @see https://schema.org/DownloadAction + * @see https://schema.org/BookmarkAction */ -#[ORM\Entity] #[ApiResource( - types: ['https://schema.org/DownloadAction'], + types: ['https://schema.org/BookmarkAction'], operations: [ - new GetCollection( - uriTemplate: '/admin/downloads{._format}', - itemUriTemplate: '/admin/downloads/{id}{._format}', - security: 'is_granted("ROLE_ADMIN")', - normalizationContext: [ - 'groups' => ['Download:read', 'Download:read:admin'], - IriTransformerNormalizer::CONTEXT_KEY => [ - 'book' => '/admin/books/{id}{._format}', - 'user' => '/admin/users/{id}{._format}', - ], - ], - ), - new Get( - uriTemplate: '/admin/downloads/{id}{._format}', - security: 'is_granted("ROLE_ADMIN")', - normalizationContext: [ - 'groups' => ['Download:read', 'Download:read:admin'], - IriTransformerNormalizer::CONTEXT_KEY => [ - 'book' => '/admin/books/{id}{._format}', - 'user' => '/admin/users/{id}{._format}', - ], - ], - ), - new GetCollection( - filters: [], // disable filters - itemUriTemplate: '/downloads/{id}{._format}' - ), + new GetCollection(), new Get(), new Post( - processor: DownloadPersistProcessor::class, - itemUriTemplate: '/downloads/{id}{._format}' + processor: BookmarkPersistProcessor::class ), ], normalizationContext: [ - 'groups' => ['Download:read'], + 'groups' => ['Bookmark:read'], IriTransformerNormalizer::CONTEXT_KEY => [ 'book' => '/books/{id}{._format}', ], ], - denormalizationContext: ['groups' => ['Download:write']], + denormalizationContext: ['groups' => ['Bookmark:write']], mercure: true, security: 'is_granted("ROLE_USER")' )] -class Download +#[ORM\Entity(repositoryClass: BookmarkRepository::class)] +class Bookmark { /** * @see https://schema.org/identifier */ - #[ORM\Id] + #[ApiProperty(identifier: true, types: ['https://schema.org/identifier'])] #[ORM\Column(type: UuidType::NAME, unique: true)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[ApiProperty(identifier: true, types: ['https://schema.org/identifier'])] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\Id] private ?Uuid $id = null; /** * @see https://schema.org/agent */ + #[ApiProperty(types: ['https://schema.org/agent'])] #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: false)] - #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] - #[ApiProperty(types: ['https://schema.org/agent'])] - #[Groups(groups: ['Download:read:admin'])] public ?User $user = null; /** * @see https://schema.org/object */ - #[ORM\ManyToOne(targetEntity: Book::class)] - #[ORM\JoinColumn(nullable: false)] #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/object'])] - #[Groups(groups: ['Download:read', 'Download:write'])] #[Assert\NotNull] + #[Groups(groups: ['Bookmark:read', 'Bookmark:write'])] + #[ORM\ManyToOne(targetEntity: Book::class)] + #[ORM\JoinColumn(nullable: false)] public ?Book $book = null; /** * @see https://schema.org/startTime */ - #[ORM\Column(type: 'datetime_immutable')] #[ApiProperty(types: ['https://schema.org/startTime'])] - #[Groups(groups: ['Download:read'])] - public ?\DateTimeInterface $downloadedAt = null; + #[Groups(groups: ['Bookmark:read'])] + #[ORM\Column(type: 'datetime_immutable')] + public ?\DateTimeInterface $bookmarkedAt = null; public function getId(): ?Uuid { diff --git a/api/src/Entity/Parchment.php b/api/src/Entity/Parchment.php index 8c62ec1d5..68966565b 100644 --- a/api/src/Entity/Parchment.php +++ b/api/src/Entity/Parchment.php @@ -14,18 +14,18 @@ /** * @deprecated create a Book instead */ -#[ORM\Entity] #[ApiResource(deprecationReason: 'Create a Book instead')] +#[ORM\Entity] class Parchment { /** * @see https://schema.org/identifier */ - #[ORM\Id] + #[ApiProperty(types: ['https://schema.org/identifier'])] #[ORM\Column(type: UuidType::NAME, unique: true)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[ApiProperty(types: ['https://schema.org/identifier'])] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\Id] private ?Uuid $id = null; public function getId(): ?Uuid @@ -36,14 +36,14 @@ public function getId(): ?Uuid /** * The title of the book. */ - #[ORM\Column] #[Assert\NotBlank(allowNull: false)] + #[ORM\Column] public ?string $title = null; /** * A description of the item. */ - #[ORM\Column] #[Assert\NotBlank(allowNull: false)] + #[ORM\Column] public ?string $description = null; } diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index 143f66e1d..433f707ab 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -4,10 +4,6 @@ namespace App\Entity; -use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -16,8 +12,10 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use App\Repository\ReviewRepository; use App\Serializer\IriTransformerNormalizer; use App\State\Processor\ReviewPersistProcessor; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; @@ -29,31 +27,30 @@ * * @see https://schema.org/Review */ -#[ORM\Entity] #[ApiResource( types: ['https://schema.org/Review'], operations: [ new GetCollection( uriTemplate: '/admin/reviews{._format}', - itemUriTemplate: '/admin/reviews/{id}{._format}' + itemUriTemplate: '/admin/reviews/{id}{._format}', + filters: ['app.filter.review.admin.search', 'app.filter.review.admin.numeric'] ), new Get( uriTemplate: '/admin/reviews/{id}{._format}' ), new Patch( - uriTemplate: '/admin/reviews/{id}{._format}', - itemUriTemplate: '/admin/reviews/{id}{._format}' + uriTemplate: '/admin/reviews/{id}{._format}' ), new Delete( uriTemplate: '/admin/reviews/{id}{._format}' ), ], normalizationContext: [ - 'groups' => ['Review:read', 'Review:read:admin'], IriTransformerNormalizer::CONTEXT_KEY => [ 'book' => '/admin/books/{id}{._format}', 'user' => '/admin/users/{id}{._format}', ], + 'groups' => ['Review:read', 'Review:read:admin'], ], denormalizationContext: ['groups' => ['Review:write']], mercure: true, @@ -67,15 +64,15 @@ ], operations: [ new GetCollection( - filters: [], // disable filters - itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}' + itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}', + paginationClientItemsPerPage: true ), new Get( uriTemplate: '/books/{bookId}/reviews/{id}{._format}', uriVariables: [ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), 'id' => new Link(fromClass: Review::class), - ], + ] ), new Post( security: 'is_granted("ROLE_USER")', @@ -88,7 +85,6 @@ 'bookId' => new Link(toProperty: 'book', fromClass: Book::class), 'id' => new Link(fromClass: Review::class), ], - itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}', security: 'is_granted("ROLE_USER") and user == object.user' ), new Delete( @@ -109,74 +105,72 @@ ], denormalizationContext: ['groups' => ['Review:write']] )] +#[ORM\Entity(repositoryClass: ReviewRepository::class)] class Review { /** * @see https://schema.org/identifier */ - #[ORM\Id] + #[ApiProperty(types: ['https://schema.org/identifier'])] #[ORM\Column(type: UuidType::NAME, unique: true)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[ApiProperty(types: ['https://schema.org/identifier'])] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\Id] private ?Uuid $id = null; /** * @see https://schema.org/author */ - #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(nullable: false)] - #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/author'])] #[Groups(groups: ['Review:read'])] + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] public ?User $user = null; /** * @see https://schema.org/itemReviewed */ - #[ORM\ManyToOne(targetEntity: Book::class)] - #[ORM\JoinColumn(nullable: false)] - #[ApiFilter(SearchFilter::class, strategy: SearchFilterInterface::STRATEGY_EXACT)] #[ApiProperty(types: ['https://schema.org/itemReviewed'])] - #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotNull] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[ORM\ManyToOne(targetEntity: Book::class)] + #[ORM\JoinColumn(nullable: false)] public ?Book $book = null; /** * @see https://schema.org/datePublished */ - #[ORM\Column(type: 'datetime_immutable')] #[ApiProperty(types: ['https://schema.org/datePublished'])] #[Groups(groups: ['Review:read'])] + #[ORM\Column(type: 'datetime_immutable')] public ?\DateTimeInterface $publishedAt = null; /** * @see https://schema.org/reviewBody */ - #[ORM\Column] #[ApiProperty(types: ['https://schema.org/reviewBody'])] - #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotBlank(allowNull: false)] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[ORM\Column(type: Types::TEXT)] public ?string $body = null; /** * @see https://schema.org/reviewRating */ - #[ORM\Column(type: 'smallint')] - #[ApiFilter(NumericFilter::class)] #[ApiProperty(types: ['https://schema.org/reviewRating'])] - #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\NotNull] #[Assert\Range(min: 0, max: 5)] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[ORM\Column(type: 'smallint')] public ?int $rating = null; /** * @deprecated use the rating property instead */ - #[ORM\Column(nullable: true)] #[ApiProperty(deprecationReason: 'Use the rating property instead.')] - #[Groups(groups: ['Review:read', 'Review:write'])] #[Assert\Choice(['a', 'b', 'c', 'd'])] + #[Groups(groups: ['Review:read', 'Review:write'])] + #[ORM\Column(nullable: true)] public ?string $letter = null; public function getId(): ?Uuid diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 68ba8c2da..6af8d33bd 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -7,6 +7,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -19,8 +20,6 @@ * * @see https://schema.org/Person */ -#[ORM\Entity] -#[ORM\Table(name: '`user`')] #[ApiResource( types: ['https://schema.org/Person'], operations: [ @@ -35,19 +34,29 @@ ], normalizationContext: ['groups' => ['User:read']] )] +#[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] #[UniqueEntity('email')] class User implements UserInterface { /** * @see https://schema.org/identifier */ - #[ORM\Id] + #[ApiProperty(types: ['https://schema.org/identifier'])] #[ORM\Column(type: UuidType::NAME, unique: true)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - #[ApiProperty(types: ['https://schema.org/identifier'])] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\Id] private ?Uuid $id = null; + /** + * @see https://schema.org/identifier + */ + #[ApiProperty(types: ['https://schema.org/identifier'])] + #[Groups(groups: ['User:read', 'Review:read'])] + #[ORM\Column(type: UuidType::NAME, unique: true)] + public ?Uuid $sub = null; + /** * @see https://schema.org/email */ @@ -57,17 +66,17 @@ class User implements UserInterface /** * @see https://schema.org/givenName */ - #[ORM\Column] #[ApiProperty(types: ['https://schema.org/givenName'])] - #[Groups(groups: ['User:read', 'Review:read', 'Download:read:admin'])] + #[Groups(groups: ['User:read', 'Review:read'])] + #[ORM\Column] public ?string $firstName = null; /** * @see https://schema.org/familyName */ - #[ORM\Column] #[ApiProperty(types: ['https://schema.org/familyName'])] - #[Groups(groups: ['User:read', 'Review:read', 'Download:read:admin'])] + #[Groups(groups: ['User:read', 'Review:read'])] + #[ORM\Column] public ?string $lastName = null; #[ORM\Column(type: 'json')] @@ -94,4 +103,19 @@ public function getUserIdentifier(): string { return (string) $this->email; } + + + /** + * @see https://schema.org/name + */ + #[ApiProperty(types: ['https://schema.org/name'])] + #[Groups(groups: ['User:read', 'Review:read'])] + public function getName(): ?string + { + if (!$this->firstName && !$this->lastName) { + return null; + } + + return trim(sprintf('%s %s', $this->firstName, $this->lastName)); + } } diff --git a/api/src/Exception/InvalidBnfResponseException.php b/api/src/Exception/InvalidBnfResponseException.php deleted file mode 100644 index 2b0b34e96..000000000 --- a/api/src/Exception/InvalidBnfResponseException.php +++ /dev/null @@ -1,9 +0,0 @@ - + * @extends ServiceEntityRepository * - * @method Download|null find($id, $lockMode = null, $lockVersion = null) - * @method Download|null findOneBy(array $criteria, array $orderBy = null) - * @method Download[] findAll() - * @method Download[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method Bookmark|null find($id, $lockMode = null, $lockVersion = null) + * @method Bookmark|null findOneBy(array $criteria, array $orderBy = null) + * @method Bookmark[] findAll() + * @method Bookmark[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class DownloadRepository extends ServiceEntityRepository +class BookmarkRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, Download::class); + parent::__construct($registry, Bookmark::class); } - public function save(Download $entity, bool $flush = false): void + public function save(Bookmark $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); @@ -30,7 +30,7 @@ public function save(Download $entity, bool $flush = false): void } } - public function remove(Download $entity, bool $flush = false): void + public function remove(Bookmark $entity, bool $flush = false): void { $this->getEntityManager()->remove($entity); @@ -40,7 +40,7 @@ public function remove(Download $entity, bool $flush = false): void } // /** - // * @return Download[] Returns an array of Download objects + // * @return Bookmark[] Returns an array of Bookmark objects // */ // public function findByExampleField($value): array // { @@ -54,7 +54,7 @@ public function remove(Download $entity, bool $flush = false): void // ; // } - // public function findOneBySomeField($value): ?Download + // public function findOneBySomeField($value): ?Bookmark // { // return $this->createQueryBuilder('b') // ->andWhere('b.exampleField = :val') diff --git a/api/src/Repository/ReviewRepository.php b/api/src/Repository/ReviewRepository.php index c8e9da7d2..40767132e 100644 --- a/api/src/Repository/ReviewRepository.php +++ b/api/src/Repository/ReviewRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Entity\Book; use App\Entity\Review; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -21,6 +22,16 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, Review::class); } + public function getAverageRating(Book $book): ?int + { + $rating = $this->createQueryBuilder('r') + ->select('AVG(r.rating)') + ->where('r.book = :book')->setParameter('book', $book) + ->getQuery()->getSingleScalarResult(); + + return $rating ? (int) $rating : null; + } + public function save(Review $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); diff --git a/api/src/Security/Core/UserProvider.php b/api/src/Security/Core/UserProvider.php index 43d554e1e..60ca8bdcf 100644 --- a/api/src/Security/Core/UserProvider.php +++ b/api/src/Security/Core/UserProvider.php @@ -10,6 +10,7 @@ use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\AttributesBasedUserProviderInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Uid\Uuid; final readonly class UserProvider implements AttributesBasedUserProviderInterface { @@ -41,15 +42,24 @@ public function loadUserByIdentifier(string $identifier, array $attributes = []) { $user = $this->repository->findOneBy(['email' => $identifier]) ?: new User(); - if (!isset($attributes['firstName'])) { - throw new UnsupportedUserException('Property "firstName" is missing in token attributes.'); + if (!isset($attributes['sub'])) { + throw new UnsupportedUserException('Property "sub" is missing in token attributes.'); } - $user->firstName = $attributes['firstName']; + try { + $user->sub = Uuid::fromString($attributes['sub']); + } catch (\Throwable $e) { + throw new UnsupportedUserException($e->getMessage(), $e->getCode(), $e); + } + + if (!isset($attributes['given_name'])) { + throw new UnsupportedUserException('Property "given_name" is missing in token attributes.'); + } + $user->firstName = $attributes['given_name']; - if (!isset($attributes['lastName'])) { - throw new UnsupportedUserException('Property "lastName" is missing in token attributes.'); + if (!isset($attributes['family_name'])) { + throw new UnsupportedUserException('Property "family_name" is missing in token attributes.'); } - $user->lastName = $attributes['lastName']; + $user->lastName = $attributes['family_name']; $this->repository->save($user); diff --git a/api/src/Security/OidcTokenGenerator.php b/api/src/Security/OidcTokenGenerator.php index 4d0b7eee3..e898bbaf8 100644 --- a/api/src/Security/OidcTokenGenerator.php +++ b/api/src/Security/OidcTokenGenerator.php @@ -12,6 +12,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\When; +use Symfony\Component\Uid\Uuid; /** * Generates a token for specified claims. @@ -38,15 +39,20 @@ public function generate(array $claims): string { // Defaults $time = time(); + $sub = Uuid::v7()->__toString(); $claims += [ + 'sub' => $sub, 'iat' => $time, 'nbf' => $time, 'exp' => $time + 3600, 'iss' => $this->issuer, 'aud' => $this->audience, - 'firstName' => 'John', - 'lastName' => 'DOE', + 'given_name' => 'John', + 'family_name' => 'DOE', ]; + if (empty($claims['sub'])) { + $claims['sub'] = $sub; + } if (empty($claims['iat'])) { $claims['iat'] = $time; } diff --git a/api/src/Serializer/BookNormalizer.php b/api/src/Serializer/BookNormalizer.php index 2cde92545..664c585ca 100644 --- a/api/src/Serializer/BookNormalizer.php +++ b/api/src/Serializer/BookNormalizer.php @@ -5,6 +5,9 @@ namespace App\Serializer; use App\Entity\Book; +use App\Repository\ReviewRepository; +use Doctrine\Persistence\ObjectRepository; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -14,8 +17,11 @@ final class BookNormalizer implements NormalizerInterface, NormalizerAwareInterf { use NormalizerAwareTrait; - public function __construct(private RouterInterface $router) - { + public function __construct( + private RouterInterface $router, + #[Autowire(service: ReviewRepository::class)] + private ObjectRepository $repository + ) { } /** @@ -23,10 +29,10 @@ public function __construct(private RouterInterface $router) */ public function normalize(mixed $object, string $format = null, array $context = []): array { - // set "reviews" on the object, and let the serializer decide if it must be exposed or not $object->reviews = $this->router->generate('_api_/books/{bookId}/reviews{._format}_get_collection', [ 'bookId' => $object->getId(), ]); + $object->rating = $this->repository->getAverageRating($object); return $this->normalizer->normalize($object, $format, $context + [self::class => true]); } @@ -35,4 +41,11 @@ public function supportsNormalization(mixed $data, string $format = null, array { return $data instanceof Book && !isset($context[self::class]); } + + public function getSupportedTypes(?string $format): array + { + return [ + Book::class => false, + ]; + } } diff --git a/api/src/Serializer/IriTransformerNormalizer.php b/api/src/Serializer/IriTransformerNormalizer.php index f8148f43f..4c378d6d1 100644 --- a/api/src/Serializer/IriTransformerNormalizer.php +++ b/api/src/Serializer/IriTransformerNormalizer.php @@ -34,6 +34,10 @@ public function normalize(mixed $object, string $format = null, array $context = } foreach ($value as $property => $uriTemplate) { + if (!isset($data[$property]) || !(is_string($data[$property]) || isset($data[$property]['@id']))) { + continue; + } + $iri = $this->iriConverter->getIriFromResource( $object->{$property}, UrlGeneratorInterface::ABS_PATH, @@ -42,7 +46,7 @@ public function normalize(mixed $object, string $format = null, array $context = if (is_string($data[$property])) { $data[$property] = $iri; - } else { + } elseif (isset($data[$property]['@id'])) { $data[$property]['@id'] = $iri; } } @@ -58,4 +62,11 @@ public function supportsNormalization(mixed $data, string $format = null, array && ItemNormalizer::FORMAT === $format && !isset($context[self::class]); } + + public function getSupportedTypes(?string $format): array + { + return [ + '*' => false, + ]; + } } diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php index 0a68c0655..9c9ca41dd 100644 --- a/api/src/State/Processor/BookPersistProcessor.php +++ b/api/src/State/Processor/BookPersistProcessor.php @@ -4,12 +4,10 @@ namespace App\State\Processor; +use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Book; -use App\Exception\InvalidBnfResponseException; -use App\Repository\BookRepository; -use Doctrine\Persistence\ObjectRepository; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\Encoder\DecoderInterface; @@ -18,10 +16,10 @@ final readonly class BookPersistProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: BookRepository::class)] - private ObjectRepository $repository, - private HttpClientInterface $bnfClient, - private DecoderInterface $decoder + #[Autowire(service: PersistProcessor::class)] + private ProcessorInterface $processor, + private HttpClientInterface $client, + private DecoderInterface $decoder ) { } @@ -30,21 +28,29 @@ public function __construct( */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book { - // call BNF API - $response = $this->bnfClient->request(Request::METHOD_GET, $data->book); - $results = $this->decoder->decode($response->getContent(), 'xml'); - if (!$title = $results['notice']['record']['metadata']['oai_dc:dc']['dc:title'] ?? null) { - throw new InvalidBnfResponseException('Missing property "dc:title" in BNF API response.'); - } - if (!$publisher = $results['notice']['record']['metadata']['oai_dc:dc']['dc:publisher'] ?? null) { - throw new InvalidBnfResponseException('Missing property "dc:publisher" in BNF API response.'); + $book = $this->getData($data->book); + $data->title = $book['title']; + + $data->author = null; + if (isset($book['authors'][0]['key'])) { + $author = $this->getData('https://openlibrary.org'.$book['authors'][0]['key']); + if (isset($author['name'])) { + $data->author = $author['name']; + } } - $data->title = $title; - $data->author = $publisher; // save entity - $this->repository->save($data, true); + $this->processor->process($data, $operation, $uriVariables, $context); return $data; } + + private function getData(string $uri): array + { + return $this->decoder->decode($this->client->request(Request::METHOD_GET, $uri, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ])->getContent(), 'json'); + } } diff --git a/api/src/State/Processor/DownloadPersistProcessor.php b/api/src/State/Processor/BookmarkPersistProcessor.php similarity index 63% rename from api/src/State/Processor/DownloadPersistProcessor.php rename to api/src/State/Processor/BookmarkPersistProcessor.php index e458ce2be..2567ba7dd 100644 --- a/api/src/State/Processor/DownloadPersistProcessor.php +++ b/api/src/State/Processor/BookmarkPersistProcessor.php @@ -6,28 +6,28 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Entity\Download; -use App\Repository\DownloadRepository; +use App\Entity\Bookmark; +use App\Repository\BookmarkRepository; use Doctrine\Persistence\ObjectRepository; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\DependencyInjection\Attribute\Autowire; -final readonly class DownloadPersistProcessor implements ProcessorInterface +final readonly class BookmarkPersistProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: DownloadRepository::class)] + #[Autowire(service: BookmarkRepository::class)] private ObjectRepository $repository, - private Security $security + private Security $security ) { } /** - * @param Download $data + * @param Bookmark $data */ - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Download + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Bookmark { $data->user = $this->security->getUser(); - $data->downloadedAt = new \DateTimeImmutable(); + $data->bookmarkedAt = new \DateTimeImmutable(); // save entity $this->repository->save($data, true); diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php index f154fef06..ea516b7fa 100644 --- a/api/tests/Api/Admin/BookTest.php +++ b/api/tests/Api/Admin/BookTest.php @@ -12,7 +12,6 @@ use App\Security\OidcTokenGenerator; use App\Tests\Api\Admin\Trait\UsersDataProviderTrait; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -38,7 +37,6 @@ public function testAsNonAdminUserICannotGetACollectionOfBooks(int $expectedCode $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -64,7 +62,6 @@ public function testAsAdminUserICanGetACollectionOfBooks(FactoryCollection $fact $factory->create(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -128,7 +125,6 @@ public function testAsAnyUserICannotGetAnInvalidBook(?UserFactory $userFactory): $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -156,7 +152,6 @@ public function testAsNonAdminUserICannotGetABook(int $expectedCode, string $hyd $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -182,7 +177,6 @@ public function testAsAdminUserICanGetABook(): void $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -208,7 +202,6 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $ $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -216,7 +209,7 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $ $this->client->request('POST', '/admin/books', $options + [ 'json' => [ - 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'book' => 'https://openlibrary.org/books/OL28346544M.json', 'condition' => BookCondition::NewCondition->value, ], ]); @@ -237,7 +230,6 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $ public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, array $violations): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -285,17 +277,19 @@ public function getInvalidData(): iterable ]; } + /** + * @group apiCall + */ public function testAsAdminUserICanCreateABook(): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); $this->client->request('POST', '/admin/books', [ 'auth_bearer' => $token, 'json' => [ - 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'book' => 'https://openlibrary.org/books/OL28346544M.json', 'condition' => BookCondition::NewCondition->value, ], ]); @@ -303,7 +297,7 @@ public function testAsAdminUserICanCreateABook(): void self::assertResponseStatusCodeSame(Response::HTTP_CREATED); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); self::assertJsonContains([ - 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'book' => 'https://openlibrary.org/books/OL28346544M.json', 'condition' => BookCondition::NewCondition->value, ]); self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); @@ -319,7 +313,6 @@ public function testAsNonAdminUserICannotUpdateBook(int $expectedCode, string $h $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -327,7 +320,7 @@ public function testAsNonAdminUserICannotUpdateBook(int $expectedCode, string $h $this->client->request('PATCH', '/admin/books/'.$book->getId(), $options + [ 'json' => [ - 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'book' => 'https://openlibrary.org/books/OL28346544M.json', 'condition' => BookCondition::NewCondition->value, ], 'headers' => [ @@ -350,7 +343,6 @@ public function testAsAdminUserICannotUpdateAnInvalidBook(): void BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -376,7 +368,6 @@ public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, ar BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -398,14 +389,16 @@ public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, ar ]); } + /** + * @group apiCall + */ public function testAsAdminUserICanUpdateABook(): void { $book = BookFactory::createOne([ - 'book' => 'https://gallica.bnf.fr/services/OAIRecord?ark=bpt6k5738219s', + 'book' => 'https://openlibrary.org/books/OL28346544M.json', ]); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -437,7 +430,6 @@ public function testAsNonAdminUserICannotDeleteABook(int $expectedCode, string $ $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -460,7 +452,6 @@ public function testAsAdminUserICannotDeleteAnInvalidBook(): void BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -474,7 +465,6 @@ public function testAsAdminUserICanDeleteABook(): void $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); diff --git a/api/tests/Api/Admin/DownloadTest.php b/api/tests/Api/Admin/DownloadTest.php deleted file mode 100644 index 991c82b2a..000000000 --- a/api/tests/Api/Admin/DownloadTest.php +++ /dev/null @@ -1,77 +0,0 @@ -client = self::createClient(); - } - - /** - * @dataProvider getNonAdminUsers - */ - public function testAsNonAdminUserICannotGetACollectionOfDownloads(int $expectedCode, string $hydraDescription, ?UserFactory $userFactory): void - { - DownloadFactory::createMany(10, ['user' => UserFactory::createOne()]); - - $options = []; - if ($userFactory) { - $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - 'email' => $userFactory->create()->email, - ]); - $options['auth_bearer'] = $token; - } - - $this->client->request('GET', '/admin/downloads', $options); - - self::assertResponseStatusCodeSame($expectedCode); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'hydra:title' => 'An error occurred', - 'hydra:description' => $hydraDescription, - ]); - } - - public function testAsAdminUserICanGetACollectionOfDownloads(): void - { - DownloadFactory::createMany(100, ['user' => UserFactory::createOne()]); - - $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), - 'email' => UserFactory::createOneAdmin()->email, - ]); - - $response = $this->client->request('GET', '/admin/downloads', ['auth_bearer' => $token]); - - self::assertResponseIsSuccessful(); - self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - 'hydra:totalItems' => 100, - ]); - self::assertCount(30, $response->toArray()['hydra:member']); - self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/collection.json')); - } -} diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php index 36d7bc27c..30ae43259 100644 --- a/api/tests/Api/Admin/ReviewTest.php +++ b/api/tests/Api/Admin/ReviewTest.php @@ -15,7 +15,6 @@ use App\Security\OidcTokenGenerator; use App\Tests\Api\Admin\Trait\UsersDataProviderTrait; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -41,7 +40,6 @@ public function testAsNonAdminUserICannotGetACollectionOfReviews(int $expectedCo $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -67,7 +65,6 @@ public function testAsAdminUserICanGetACollectionOfReviews(FactoryCollection $fa $factory->create(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -93,6 +90,11 @@ public function getAdminUrls(): iterable '/admin/reviews', 100, ]; + yield 'all reviews using itemsPerPage' => [ + ReviewFactory::new()->many(100), + '/admin/reviews?itemsPerPage=10', + 100, + ]; yield 'reviews filtered by rating' => [ ReviewFactory::new()->sequence(function () { foreach (range(1, 100) as $i) { @@ -105,7 +107,7 @@ public function getAdminUrls(): iterable ]; yield 'reviews filtered by user' => [ ReviewFactory::new()->sequence(function () { - $user = UserFactory::createOne(['email' => 'john.doe@example.com']); + $user = UserFactory::createOne(['email' => 'user@example.com']); yield ['user' => $user]; foreach (range(1, 10) as $i) { yield ['user' => UserFactory::createOne()]; @@ -113,7 +115,7 @@ public function getAdminUrls(): iterable }), static function (): string { /** @var User[] $users */ - $users = UserFactory::findBy(['email' => 'john.doe@example.com']); + $users = UserFactory::findBy(['email' => 'user@example.com']); return '/admin/reviews?user=/admin/users/'.$users[0]->getId(); }, @@ -146,7 +148,6 @@ public function testAsNonAdminUserICannotGetAReview(int $expectedCode, string $h $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -167,7 +168,6 @@ public function testAsNonAdminUserICannotGetAReview(int $expectedCode, string $h public function testAsAdminUserICannotGetAnInvalidReview(): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -181,7 +181,6 @@ public function testAsAdminUserICanGetAReview(): void $review = ReviewFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -202,7 +201,6 @@ public function testAsNonAdminUserICannotUpdateAReview(int $expectedCode, string $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -228,7 +226,6 @@ public function testAsNonAdminUserICannotUpdateAReview(int $expectedCode, string public function testAsAdminUserICannotUpdateAnInvalidReview(): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -251,7 +248,6 @@ public function testAsAdminUserICanUpdateAReview(): void $review = ReviewFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -285,7 +281,6 @@ public function testAsNonAdminUserICannotDeleteAReview(int $expectedCode, string $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -306,7 +301,6 @@ public function testAsNonAdminUserICannotDeleteAReview(int $expectedCode, string public function testAsAdminUserICannotDeleteAnInvalidReview(): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -321,7 +315,6 @@ public function testAsAdminUserICanDeleteAReview(): void $id = $review->getId(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); diff --git a/api/tests/Api/Admin/UserTest.php b/api/tests/Api/Admin/UserTest.php index 5a43e682e..270575a39 100644 --- a/api/tests/Api/Admin/UserTest.php +++ b/api/tests/Api/Admin/UserTest.php @@ -37,7 +37,6 @@ public function testAsNonAdminUserICannotGetAUser(int $expectedCode, string $hyd $options = []; if ($userFactory) { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $userFactory->create()->email, ]); $options['auth_bearer'] = $token; @@ -60,7 +59,6 @@ public function testAsAdminUserICanGetAUser(): void $user = UserFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOneAdmin()->email, ]); @@ -80,13 +78,16 @@ public function testAsAUserIAmUpdatedOnLogin(): void $user = UserFactory::createOne([ 'firstName' => 'John', 'lastName' => 'DOE', + 'sub' => Uuid::fromString('b5c5bff1-5b5f-4a73-8fc8-4ea8f18586a9'), ])->disableAutoRefresh(); + $sub = Uuid::v7()->__toString(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), + 'sub' => $sub, 'email' => $user->email, - 'firstName' => 'Chuck', - 'lastName' => 'NORRIS', + 'given_name' => 'Chuck', + 'family_name' => 'NORRIS', + 'name' => 'Chuck NORRIS', ]); $this->client->request('GET', '/books', ['auth_bearer' => $token]); @@ -96,5 +97,7 @@ public function testAsAUserIAmUpdatedOnLogin(): void self::assertNotNull($user); self::assertEquals('Chuck', $user->firstName); self::assertEquals('NORRIS', $user->lastName); + self::assertEquals('Chuck NORRIS', $user->getName()); + self::assertEquals($sub, $user->sub); } } diff --git a/api/tests/Api/Admin/schemas/Review/collection.json b/api/tests/Api/Admin/schemas/Review/collection.json index 1b44a9e4c..181231e03 100644 --- a/api/tests/Api/Admin/schemas/Review/collection.json +++ b/api/tests/Api/Admin/schemas/Review/collection.json @@ -131,6 +131,12 @@ "type": "string", "pattern": "^https://schema.org/Person$" }, + "sub": { + "externalDocs": { + "url": "https:\/\/schema.org\/identifier" + }, + "type": "string" + }, "firstName": { "description": "The givenName of the person", "externalDocs": { @@ -144,13 +150,22 @@ "url": "https:\/\/schema.org\/familyName" }, "type": "string" + }, + "name": { + "description": "The name of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/name" + }, + "type": "string" } }, "required": [ "@id", "@type", + "sub", "firstName", - "lastName" + "lastName", + "name" ] } }, diff --git a/api/tests/Api/Admin/schemas/Review/item.json b/api/tests/Api/Admin/schemas/Review/item.json index 57e4dabf1..f2e6902e6 100644 --- a/api/tests/Api/Admin/schemas/Review/item.json +++ b/api/tests/Api/Admin/schemas/Review/item.json @@ -38,6 +38,12 @@ "type": "string", "pattern": "^https://schema.org/Person$" }, + "sub": { + "externalDocs": { + "url": "https:\/\/schema.org\/identifier" + }, + "type": "string" + }, "firstName": { "description": "The givenName of the person", "externalDocs": { @@ -51,13 +57,22 @@ "url": "https:\/\/schema.org\/familyName" }, "type": "string" + }, + "name": { + "description": "The name of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/name" + }, + "type": "string" } }, "required": [ "@id", "@type", + "sub", "firstName", - "lastName" + "lastName", + "name" ] }, "book": { diff --git a/api/tests/Api/Admin/schemas/User/item.json b/api/tests/Api/Admin/schemas/User/item.json index d775791da..0c24d77f0 100644 --- a/api/tests/Api/Admin/schemas/User/item.json +++ b/api/tests/Api/Admin/schemas/User/item.json @@ -18,6 +18,12 @@ "type": "string", "pattern": "^https://schema.org/Person$" }, + "sub": { + "externalDocs": { + "url": "https:\/\/schema.org\/identifier" + }, + "type": "string" + }, "firstName": { "description": "The givenName of the person", "externalDocs": { @@ -31,13 +37,22 @@ "url": "https:\/\/schema.org\/familyName" }, "type": "string" + }, + "name": { + "description": "The name of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/name" + }, + "type": "string" } }, "required": [ "@context", "@id", "@type", + "sub", "firstName", - "lastName" + "lastName", + "name" ] } diff --git a/api/tests/Api/BookTest.php b/api/tests/Api/BookTest.php index bdcb18d43..05e47da2f 100644 --- a/api/tests/Api/BookTest.php +++ b/api/tests/Api/BookTest.php @@ -7,6 +7,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; use App\DataFixtures\Factory\BookFactory; +use App\DataFixtures\Factory\ReviewFactory; use App\Enum\BookCondition; use Symfony\Component\HttpFoundation\Response; use Zenstruck\Foundry\FactoryCollection; @@ -94,6 +95,11 @@ public function testAsAnonymousICannotGetAnInvalidBook(): void public function testAsAnonymousICanGetABook(): void { $book = BookFactory::createOne(); + ReviewFactory::createOne(['rating' => 1, 'book' => $book]); + ReviewFactory::createOne(['rating' => 2, 'book' => $book]); + ReviewFactory::createOne(['rating' => 3, 'book' => $book]); + ReviewFactory::createOne(['rating' => 4, 'book' => $book]); + ReviewFactory::createOne(['rating' => 5, 'book' => $book]); $this->client->request('GET', '/books/'.$book->getId()); @@ -105,6 +111,8 @@ public function testAsAnonymousICanGetABook(): void 'condition' => $book->condition->value, 'title' => $book->title, 'author' => $book->author, + 'reviews' => '/books/'.$book->getId().'/reviews', + 'rating' => 3, ]); self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json')); } diff --git a/api/tests/Api/DownloadTest.php b/api/tests/Api/BookmarkTest.php similarity index 75% rename from api/tests/Api/DownloadTest.php rename to api/tests/Api/BookmarkTest.php index f829f4e9a..377d9a6f3 100644 --- a/api/tests/Api/DownloadTest.php +++ b/api/tests/Api/BookmarkTest.php @@ -7,15 +7,14 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; use App\DataFixtures\Factory\BookFactory; -use App\DataFixtures\Factory\DownloadFactory; +use App\DataFixtures\Factory\BookmarkFactory; use App\DataFixtures\Factory\UserFactory; use App\Security\OidcTokenGenerator; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; -final class DownloadTest extends ApiTestCase +final class BookmarkTest extends ApiTestCase { use Factories; use ResetDatabase; @@ -27,11 +26,11 @@ protected function setup(): void $this->client = self::createClient(); } - public function testAsAnonymousICannotGetACollectionOfDownloads(): void + public function testAsAnonymousICannotGetACollectionOfBookmarks(): void { - DownloadFactory::createMany(100); + BookmarkFactory::createMany(100); - $this->client->request('GET', '/downloads'); + $this->client->request('GET', '/bookmarks'); self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); @@ -44,20 +43,19 @@ public function testAsAnonymousICannotGetACollectionOfDownloads(): void } /** - * Filters are disabled on /downloads. + * Filters are disabled on /bookmarks. */ - public function testAsAUserICanGetACollectionOfMyDownloadsWithoutFilters(): void + public function testAsAUserICanGetACollectionOfMyBookmarksWithoutFilters(): void { - DownloadFactory::createMany(60); + BookmarkFactory::createMany(60); $user = UserFactory::createOne(); - DownloadFactory::createMany(40, ['user' => $user]); + BookmarkFactory::createMany(40, ['user' => $user]); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $user->email, ]); - $response = $this->client->request('GET', '/downloads', ['auth_bearer' => $token]); + $response = $this->client->request('GET', '/bookmarks', ['auth_bearer' => $token]); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); @@ -65,14 +63,14 @@ public function testAsAUserICanGetACollectionOfMyDownloadsWithoutFilters(): void 'hydra:totalItems' => 40, ]); self::assertCount(30, $response->toArray()['hydra:member']); - self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/collection.json')); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Bookmark/collection.json')); } - public function testAsAnonymousICannotCreateADownload(): void + public function testAsAnonymousICannotCreateABookmark(): void { - $book = BookFactory::createOne(); + $book = BookFactory::createOne(['book' => 'https://openlibrary.org/books/OL28346544M.json']); - $this->client->request('POST', '/downloads', [ + $this->client->request('POST', '/bookmarks', [ 'json' => [ 'book' => '/books/'.$book->getId(), ], @@ -88,15 +86,14 @@ public function testAsAnonymousICannotCreateADownload(): void ]); } - public function testAsAUserICannotCreateADownloadWithInvalidData(): void + public function testAsAUserICannotCreateABookmarkWithInvalidData(): void { $this->markTestIncomplete('Identifier "id" could not be transformed.'); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); - $this->client->request('POST', '/downloads', [ + $this->client->request('POST', '/bookmarks', [ 'json' => [ 'book' => '/books/invalid', ], @@ -118,17 +115,16 @@ public function testAsAUserICannotCreateADownloadWithInvalidData(): void ]); } - public function testAsAUserICanCreateADownload(): void + public function testAsAUserICanCreateABookmark(): void { - $book = BookFactory::createOne(); + $book = BookFactory::createOne(['book' => 'https://openlibrary.org/books/OL28346544M.json']); $user = UserFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $user->email, ]); - $this->client->request('POST', '/downloads', [ + $this->client->request('POST', '/bookmarks', [ 'json' => [ 'book' => '/books/'.$book->getId(), ], @@ -142,6 +138,6 @@ public function testAsAUserICanCreateADownload(): void '@id' => '/books/'.$book->getId(), ], ]); - self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Download/item.json')); + self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Bookmark/item.json')); } } diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php index 8dd9caa8e..35ed57476 100644 --- a/api/tests/Api/ReviewTest.php +++ b/api/tests/Api/ReviewTest.php @@ -14,7 +14,6 @@ use App\Repository\ReviewRepository; use App\Security\OidcTokenGenerator; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -36,7 +35,7 @@ protected function setup(): void * * @dataProvider getUrls */ - public function testAsAnonymousICanGetACollectionOfBookReviewsWithoutFilters(FactoryCollection $factory, string|callable $url, int $hydraTotalItems): void + public function testAsAnonymousICanGetACollectionOfBookReviewsWithoutFilters(FactoryCollection $factory, string|callable $url, int $hydraTotalItems, int $totalHydraMember = 30): void { $factory->create(); @@ -51,7 +50,7 @@ public function testAsAnonymousICanGetACollectionOfBookReviewsWithoutFilters(Fac self::assertJsonContains([ 'hydra:totalItems' => $hydraTotalItems, ]); - self::assertCount(min($hydraTotalItems, 30), $response->toArray()['hydra:member']); + self::assertCount(min($hydraTotalItems, $totalHydraMember), $response->toArray()['hydra:member']); self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/collection.json')); } @@ -72,6 +71,22 @@ static function (): string { }, 100, ]; + yield 'all book reviews using itemsPerPage' => [ + ReviewFactory::new()->sequence(function () { + $book = BookFactory::createOne(['title' => 'Foundation']); + foreach (range(1, 100) as $i) { + yield ['book' => $book]; + } + }), + static function (): string { + /** @var Book[] $books */ + $books = BookFactory::findBy(['title' => 'Foundation']); + + return '/books/'.$books[0]->getId().'/reviews?itemsPerPage=10'; + }, + 100, + 10, + ]; yield 'book reviews filtered by rating' => [ ReviewFactory::new()->sequence(function () { $book = BookFactory::createOne(['title' => 'Foundation']); @@ -91,7 +106,7 @@ static function (): string { yield 'book reviews filtered by user' => [ ReviewFactory::new()->sequence(function () { $book = BookFactory::createOne(['title' => 'Foundation']); - yield ['book' => $book, 'user' => UserFactory::createOne(['email' => 'john.doe@example.com'])]; + yield ['book' => $book, 'user' => UserFactory::createOne(['email' => 'user@example.com'])]; foreach (range(1, 99) as $i) { yield ['book' => $book, 'user' => UserFactory::createOne()]; } @@ -100,7 +115,7 @@ static function (): string { /** @var Book[] $books */ $books = BookFactory::findBy(['title' => 'Foundation']); /** @var User[] $users */ - $users = UserFactory::findBy(['email' => 'john.doe@example.com']); + $users = UserFactory::findBy(['email' => 'user@example.com']); return '/books/'.$books[0]->getId().'/reviews?user=/users/'.$users[0]->getId(); }, @@ -138,7 +153,6 @@ public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); @@ -197,7 +211,6 @@ public function testAsAUserICanAddAReviewOnABook(): void $user = UserFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $user->email, ]); @@ -272,7 +285,6 @@ public function testAsAUserICannotUpdateABookReviewOfAnotherUser(): void $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); @@ -302,7 +314,6 @@ public function testAsAUserICannotUpdateAnInvalidBookReview(): void $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); @@ -325,7 +336,6 @@ public function testAsAUserICanUpdateMyBookReview(): void $review = ReviewFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $review->user->email, ]); @@ -370,7 +380,6 @@ public function testAsAUserICannotDeleteABookReviewOfAnotherUser(): void $review = ReviewFactory::createOne(['user' => UserFactory::createOne()]); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); @@ -393,7 +402,6 @@ public function testAsAUserICannotDeleteAnInvalidBookReview(): void $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => UserFactory::createOne()->email, ]); @@ -413,7 +421,6 @@ public function testAsAUserICanDeleteMyBookReview(): void $id = $review->getId(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ - 'sub' => Uuid::v4()->__toString(), 'email' => $review->user->email, ]); diff --git a/api/tests/Api/schemas/Book/collection.json b/api/tests/Api/schemas/Book/collection.json index 411bdae6e..d227544be 100644 --- a/api/tests/Api/schemas/Book/collection.json +++ b/api/tests/Api/schemas/Book/collection.json @@ -67,6 +67,13 @@ "type": "string", "format": "iri-reference", "pattern": "^/books/.+/reviews$" + }, + "rating": { + "description": "The overall rating, based on a collection of reviews or ratings, of the item", + "externalDocs": { + "url": "https:\/\/schema.org\/aggregateRating" + }, + "type": "number" } }, "required": [ diff --git a/api/tests/Api/schemas/Book/item.json b/api/tests/Api/schemas/Book/item.json index d4eef56ae..357799806 100644 --- a/api/tests/Api/schemas/Book/item.json +++ b/api/tests/Api/schemas/Book/item.json @@ -68,6 +68,13 @@ "type": "string", "format": "iri-reference", "pattern": "^/books/.+/reviews$" + }, + "rating": { + "description": "The overall rating, based on a collection of reviews or ratings, of the item", + "externalDocs": { + "url": "https:\/\/schema.org\/aggregateRating" + }, + "type": "number" } }, "required": [ diff --git a/api/tests/Api/Admin/schemas/Download/collection.json b/api/tests/Api/schemas/Bookmark/collection.json similarity index 74% rename from api/tests/Api/Admin/schemas/Download/collection.json rename to api/tests/Api/schemas/Bookmark/collection.json index d75fbf0df..fdd94265a 100644 --- a/api/tests/Api/Admin/schemas/Download/collection.json +++ b/api/tests/Api/schemas/Bookmark/collection.json @@ -3,38 +3,30 @@ "type": "object", "additionalProperties": false, "definitions": { - "Download:jsonld": { + "Bookmark:jsonld": { "type": "object", "additionalProperties": false, "properties": { "@type": { "readOnly": true, "type": "string", - "pattern": "^https:\/\/schema.org\/DownloadAction$" + "pattern": "^https:\/\/schema.org\/BookmarkAction$" }, "@id": { "readOnly": true, "type": "string", - "pattern": "^/admin/downloads/.+$" + "pattern": "^/bookmarks/.+$" }, "book": { - "description": "The object of the download", + "description": "The object of the bookmark", "externalDocs": { "url": "https:\/\/schema.org\/object" }, "type": "object", "$ref": "#\/definitions\/Book:jsonld" }, - "user": { - "description": "The direct performer or driver of the action (animate or inanimate)", - "externalDocs": { - "url": "https:\/\/schema.org\/agent" - }, - "type": "object", - "$ref": "#\/definitions\/User:jsonld" - }, - "downloadedAt": { - "description": "The date time of the download", + "bookmarkedAt": { + "description": "The date time of the bookmark", "externalDocs": { "url": "https:\/\/schema.org\/startTime" }, @@ -46,7 +38,7 @@ "@id", "@type", "book", - "downloadedAt" + "bookmarkedAt" ] }, "Book:jsonld": { @@ -69,7 +61,15 @@ "@id": { "readOnly": true, "type": "string", - "pattern": "^/admin/books/.+$" + "pattern": "^/books/.+$" + }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" }, "title": { "description": "The title of the book", @@ -84,49 +84,44 @@ "url": "https:\/\/schema.org\/author" }, "type": "string" - } - }, - "required": [ - "@id", - "@type", - "title", - "author" - ] - }, - "User:jsonld": { - "type": "object", - "additionalProperties": false, - "properties": { - "@type": { - "readOnly": true, - "type": "string", - "pattern": "^https://schema.org/Person$" }, - "@id": { - "readOnly": true, - "type": "string", - "pattern": "^/admin/users/.+$" - }, - "firstName": { - "description": "The givenName of the person", + "condition": { + "description": "The condition of the book", "externalDocs": { - "url": "https:\/\/schema.org\/givenName" + "url": "https:\/\/schema.org\/OfferItemCondition" }, - "type": "string" + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + }, + "reviews": { + "description": "The IRI of the book reviews", + "externalDocs": { + "url": "https:\/\/schema.org\/reviews" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+/reviews$" }, - "lastName": { - "description": "The familyName of the person", + "rating": { + "description": "The overall rating, based on a collection of reviews or ratings, of the item", "externalDocs": { - "url": "https:\/\/schema.org\/familyName" + "url": "https:\/\/schema.org\/aggregateRating" }, - "type": "string" + "type": "number" } }, "required": [ "@id", "@type", - "firstName", - "lastName" + "book", + "title", + "author", + "condition", + "reviews" ] } }, @@ -134,7 +129,7 @@ "@context": { "readOnly": true, "type": "string", - "pattern": "^/contexts/Download$" + "pattern": "^/contexts/Bookmark$" }, "@type": { "readOnly": true, @@ -144,12 +139,12 @@ "@id": { "readOnly": true, "type": "string", - "pattern": "^/admin/downloads$" + "pattern": "^/bookmarks$" }, "hydra:member": { "type": "array", "items": { - "$ref": "#\/definitions\/Download:jsonld" + "$ref": "#\/definitions\/Bookmark:jsonld" } }, "hydra:totalItems": { diff --git a/api/tests/Api/schemas/Download/item.json b/api/tests/Api/schemas/Bookmark/item.json similarity index 52% rename from api/tests/Api/schemas/Download/item.json rename to api/tests/Api/schemas/Bookmark/item.json index dfa38593d..6e0ea6aee 100644 --- a/api/tests/Api/schemas/Download/item.json +++ b/api/tests/Api/schemas/Bookmark/item.json @@ -6,20 +6,20 @@ "@context": { "readOnly": true, "type": "string", - "pattern": "^/contexts/Download$" + "pattern": "^/contexts/Bookmark$" }, "@type": { "readOnly": true, "type": "string", - "pattern": "^https://schema.org/DownloadAction$" + "pattern": "^https://schema.org/BookmarkAction$" }, "@id": { "readOnly": true, "type": "string", - "pattern": "^/downloads/.+$" + "pattern": "^/bookmarks/.+$" }, "book": { - "description": "The object of the download", + "description": "The object of the bookmark", "externalDocs": { "url": "https:\/\/schema.org\/object" }, @@ -44,6 +44,14 @@ "type": "string", "pattern": "^/books/.+$" }, + "book": { + "description": "The IRI of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/itemOffered" + }, + "type": "string", + "format": "uri" + }, "title": { "description": "The title of the book", "externalDocs": { @@ -57,17 +65,48 @@ "url": "https:\/\/schema.org\/author" }, "type": "string" + }, + "condition": { + "description": "The condition of the book", + "externalDocs": { + "url": "https:\/\/schema.org\/OfferItemCondition" + }, + "enum": [ + "https://schema.org/NewCondition", + "https://schema.org/RefurbishedCondition", + "https://schema.org/DamagedCondition", + "https://schema.org/UsedCondition" + ] + }, + "reviews": { + "description": "The IRI of the book reviews", + "externalDocs": { + "url": "https:\/\/schema.org\/reviews" + }, + "type": "string", + "format": "iri-reference", + "pattern": "^/books/.+/reviews$" + }, + "rating": { + "description": "The overall rating, based on a collection of reviews or ratings, of the item", + "externalDocs": { + "url": "https:\/\/schema.org\/aggregateRating" + }, + "type": "number" } }, "required": [ "@id", "@type", + "book", "title", - "author" + "author", + "condition", + "reviews" ] }, - "downloadedAt": { - "description": "The date time of the download", + "bookmarkedAt": { + "description": "The date time of the bookmark", "externalDocs": { "url": "https:\/\/schema.org\/startTime" }, @@ -80,6 +119,6 @@ "@type", "@id", "book", - "downloadedAt" + "bookmarkedAt" ] } diff --git a/api/tests/Api/schemas/Download/collection.json b/api/tests/Api/schemas/Download/collection.json deleted file mode 100644 index 920526af7..000000000 --- a/api/tests/Api/schemas/Download/collection.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "$schema": "https:\/\/json-schema.org\/draft-07\/schema#", - "type": "object", - "additionalProperties": false, - "definitions": { - "Download:jsonld": { - "type": "object", - "additionalProperties": false, - "properties": { - "@type": { - "readOnly": true, - "type": "string", - "pattern": "^https:\/\/schema.org\/DownloadAction$" - }, - "@id": { - "readOnly": true, - "type": "string", - "pattern": "^/downloads/.+$" - }, - "book": { - "description": "The object of the download", - "externalDocs": { - "url": "https:\/\/schema.org\/object" - }, - "type": "object", - "$ref": "#\/definitions\/Book:jsonld" - }, - "downloadedAt": { - "description": "The date time of the download", - "externalDocs": { - "url": "https:\/\/schema.org\/startTime" - }, - "type": "string", - "format": "date-time" - } - }, - "required": [ - "@id", - "@type", - "book", - "downloadedAt" - ] - }, - "Book:jsonld": { - "type": "object", - "additionalProperties": false, - "properties": { - "@type": { - "readOnly": true, - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "string", - "enum": [ - "https://schema.org/Book", - "https://schema.org/Offer" - ] - } - }, - "@id": { - "readOnly": true, - "type": "string", - "pattern": "^/books/.+$" - }, - "title": { - "description": "The title of the book", - "externalDocs": { - "url": "https:\/\/schema.org\/title" - }, - "type": "string" - }, - "author": { - "description": "The author of the book", - "externalDocs": { - "url": "https:\/\/schema.org\/author" - }, - "type": "string" - } - }, - "required": [ - "@id", - "@type", - "title", - "author" - ] - } - }, - "properties": { - "@context": { - "readOnly": true, - "type": "string", - "pattern": "^/contexts/Download$" - }, - "@type": { - "readOnly": true, - "type": "string", - "pattern": "^hydra:Collection$" - }, - "@id": { - "readOnly": true, - "type": "string", - "pattern": "^/downloads$" - }, - "hydra:member": { - "type": "array", - "items": { - "$ref": "#\/definitions\/Download:jsonld" - } - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": { - "type": "string", - "format": "iri-reference" - }, - "@type": { - "type": "string" - }, - "hydra:first": { - "type": "string", - "format": "iri-reference" - }, - "hydra:last": { - "type": "string", - "format": "iri-reference" - }, - "hydra:next": { - "type": "string", - "format": "iri-reference" - } - } - } - }, - "required": [ - "@context", - "@type", - "@id", - "hydra:member", - "hydra:totalItems", - "hydra:view" - ] -} diff --git a/api/tests/Api/schemas/Review/collection.json b/api/tests/Api/schemas/Review/collection.json index 268523e6e..1779749f2 100644 --- a/api/tests/Api/schemas/Review/collection.json +++ b/api/tests/Api/schemas/Review/collection.json @@ -88,6 +88,12 @@ "type": "string", "pattern": "^https://schema.org/Person$" }, + "sub": { + "externalDocs": { + "url": "https:\/\/schema.org\/identifier" + }, + "type": "string" + }, "firstName": { "description": "The givenName of the person", "externalDocs": { @@ -101,13 +107,22 @@ "url": "https:\/\/schema.org\/familyName" }, "type": "string" + }, + "name": { + "description": "The name of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/name" + }, + "type": "string" } }, "required": [ "@id", "@type", + "sub", "firstName", - "lastName" + "lastName", + "name" ] } }, diff --git a/api/tests/Api/schemas/Review/item.json b/api/tests/Api/schemas/Review/item.json index 00e60d7e1..4578faf03 100644 --- a/api/tests/Api/schemas/Review/item.json +++ b/api/tests/Api/schemas/Review/item.json @@ -38,6 +38,12 @@ "type": "string", "pattern": "^https://schema.org/Person$" }, + "sub": { + "externalDocs": { + "url": "https:\/\/schema.org\/identifier" + }, + "type": "string" + }, "firstName": { "description": "The givenName of the person", "externalDocs": { @@ -51,13 +57,22 @@ "url": "https:\/\/schema.org\/familyName" }, "type": "string" + }, + "name": { + "description": "The name of the person", + "externalDocs": { + "url": "https:\/\/schema.org\/name" + }, + "type": "string" } }, "required": [ "@id", "@type", + "sub", "firstName", - "lastName" + "lastName", + "name" ] }, "book": { diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d9d091cfb..b7b3277e7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,26 +1,11 @@ -version: "3.4" +version: "3.8" # Development environment override services: php: build: - target: app_php_dev - volumes: - - ./api:/srv/app - - ./api/docker/php/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro - # If you develop on Mac or Windows you can remove the vendor/ directory - # from the bind-mount for better performance by enabling the next line: - #- /srv/app/vendor - environment: - # See https://xdebug.org/docs/all_settings#mode - XDEBUG_MODE: "${XDEBUG_MODE:-off}" - extra_hosts: - # Ensure that host.docker.internal is correctly defined on Linux - - host.docker.internal:host-gateway - - consumer: - build: - target: app_php_dev + context: ./api + target: php_dev volumes: - ./api:/srv/app - ./api/docker/php/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro @@ -49,6 +34,9 @@ services: NODE_TLS_REJECT_UNAUTHORIZED: "0" caddy: + build: + context: api/ + target: caddy_base volumes: - ./api/public:/srv/app/public:ro - ./api/docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5dd8f7aab..32c4a7747 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,22 +1,26 @@ -version: "3.4" +version: "3.8" # Production environment override services: php: - environment: - APP_SECRET: ${APP_SECRET} - MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET} - - consumer: + build: + context: ./api + target: php_prod environment: APP_SECRET: ${APP_SECRET} MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET} pwa: + build: + context: ./pwa + target: prod environment: NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} caddy: + build: + context: api/ + target: caddy_prod environment: MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} diff --git a/docker-compose.yml b/docker-compose.yml index a6fc1a936..66fce967b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,8 @@ -version: "3.4" +version: "3.8" services: php: - build: - context: ./api - target: app_php - cache_from: - - ${PHP_DOCKER_IMAGE:-api-platform/php:latest} + image: app_php depends_on: - database restart: unless-stopped @@ -18,8 +14,6 @@ services: retries: 3 start_period: 30s environment: &php-env - API_ENTRYPOINT_SCHEME: https - API_ENTRYPOINT_HOST: ${SERVER_NAME:-localhost} DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} TRUSTED_HOSTS: ^${SERVER_NAME:-example\.com|localhost}|caddy$$ @@ -30,11 +24,7 @@ services: OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://caddy/oidc/realms/demo} pwa: - build: - context: ./pwa - target: prod - cache_from: - - ${PWA_DOCKER_IMAGE:-api-platform/pwa:latest} + image: app_pwa environment: NEXT_PUBLIC_ENTRYPOINT: http://caddy NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-!ChangeThisNextAuthSecret!} @@ -44,12 +34,7 @@ services: OIDC_SERVER_URL: ${OIDC_SERVER_URL_INTERNAL:-http://caddy/oidc/realms/demo} caddy: - build: - context: api/ - target: app_caddy - cache_from: - - ${PHP_DOCKER_IMAGE:-api-platform/php:latest} - - ${CADDY_DOCKER_IMAGE:-api-platform/caddy:latest} + image: app_caddy depends_on: - php - pwa diff --git a/helm/api-platform/Chart.lock b/helm/api-platform/Chart.lock index ac083be4c..ca00e3bbc 100644 --- a/helm/api-platform/Chart.lock +++ b/helm/api-platform/Chart.lock @@ -1,12 +1,12 @@ dependencies: - name: postgresql repository: https://charts.bitnami.com/bitnami/ - version: 12.4.2 + version: 12.4.3 - name: external-dns repository: https://charts.bitnami.com/bitnami/ version: 6.18.0 - name: keycloak repository: https://charts.bitnami.com/bitnami/ - version: 14.4.0 -digest: sha256:f5cc58c29ea19d89eb7e6d44f177836dea0e01bcd6a4187b0ec29dccf519e811 -generated: "2023-04-27T11:33:03.049622359+02:00" + version: 14.4.2 +digest: sha256:5b781e99f324ca23c972036c903fbf0c914ff509d1a9becb3db71e39f3d70558 +generated: "2023-08-01T14:56:57.955915354+02:00" diff --git a/helm/api-platform/README.md b/helm/api-platform/README.md index d27f268d3..548694136 100644 --- a/helm/api-platform/README.md +++ b/helm/api-platform/README.md @@ -1,6 +1,6 @@ # Deploying to a Kubernetes Cluster -API Platform comes with a native integration with [Kubernetes](https://kubernetes.io/) and the [Helm](https://helm.sh/) +API Platform comes with native integration with [Kubernetes](https://kubernetes.io/) and the [Helm](https://helm.sh/) package manager. [Learn how to deploy in the dedicated documentation entry](https://api-platform.com/docs/deployment/kubernetes/). diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json index 19a70fd7d..b748cd052 100755 --- a/helm/api-platform/keycloak/config/realm-demo.json +++ b/helm/api-platform/keycloak/config/realm-demo.json @@ -5,12 +5,26 @@ "registrationAllowed": false, "users": [ { - "username": "admin", + "username": "chuck.norris", "enabled": true, "emailVerified": true, "firstName": "Chuck", - "lastName": "NORRIS", - "email": "admin@example.com", + "lastName": "Norris", + "email": "chuck.norris@example.com", + "credentials": [ + { + "type": "password", + "value": "Pa55w0rd" + } + ] + }, + { + "username": "john.doe", + "enabled": true, + "emailVerified": true, + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com", "credentials": [ { "type": "password", diff --git a/helm/api-platform/templates/configmap.yaml b/helm/api-platform/templates/configmap.yaml index 0b9e068fe..4606c1a68 100644 --- a/helm/api-platform/templates/configmap.yaml +++ b/helm/api-platform/templates/configmap.yaml @@ -5,7 +5,6 @@ metadata: labels: {{- include "api-platform.labels" . | nindent 4 }} data: - host: {{ (first .Values.ingress.hosts).host | quote }} php-app-env: {{ .Values.php.appEnv | quote }} php-app-debug: {{ .Values.php.appDebug | quote }} php-cors-allow-origin: {{ .Values.php.corsAllowOrigin | quote }} diff --git a/helm/api-platform/templates/cronjob.yaml b/helm/api-platform/templates/cronjob.yaml index c831e8ff9..5de81d583 100644 --- a/helm/api-platform/templates/cronjob.yaml +++ b/helm/api-platform/templates/cronjob.yaml @@ -28,11 +28,6 @@ spec: command: ['/bin/sh', '-c'] args: ['composer install --prefer-dist --no-progress --no-interaction && bin/console doctrine:schema:drop --force --no-interaction && bin/console doctrine:migrations:version --delete --all --no-interaction && bin/console doctrine:migrations:migrate --no-interaction && bin/console doctrine:fixtures:load --no-interaction'] env: - - name: API_ENTRYPOINT_HOST - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: host - name: OIDC_SERVER_URL valueFrom: configMapKeyRef: diff --git a/helm/api-platform/templates/deployment.yaml b/helm/api-platform/templates/deployment.yaml index 1f238ae7d..8d286086c 100644 --- a/helm/api-platform/templates/deployment.yaml +++ b/helm/api-platform/templates/deployment.yaml @@ -87,11 +87,6 @@ spec: image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.php.image.pullPolicy }} env: - - name: API_ENTRYPOINT_HOST - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: host - name: OIDC_SERVER_URL valueFrom: configMapKeyRef: @@ -177,93 +172,6 @@ spec: periodSeconds: 3 resources: {{- toYaml .Values.resources | nindent 12 }} - - name: {{ .Chart.Name }}-consumer - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.php.image.pullPolicy }} - args: ['bin/console', 'messenger:consume'] - env: - - name: API_ENTRYPOINT_HOST - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: host - - name: OIDC_SERVER_URL - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: oidc-server-url - - name: OIDC_SERVER_URL_INTERNAL - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: oidc-server-url-internal - - name: TRUSTED_HOSTS - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-trusted-hosts - - name: TRUSTED_PROXIES - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-trusted-proxies - - name: APP_ENV - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-app-env - - name: APP_DEBUG - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-app-debug - - name: APP_SECRET - valueFrom: - secretKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-app-secret - - name: CORS_ALLOW_ORIGIN - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: php-cors-allow-origin - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: {{ include "api-platform.fullname" . }} - key: database-url - - name: MERCURE_URL - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: mercure-url - - name: MERCURE_PUBLIC_URL - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: mercure-public-url - - name: MERCURE_JWT_SECRET - valueFrom: - secretKeyRef: - name: {{ include "api-platform.fullname" . }} - key: mercure-jwt-secret - startupProbe: - exec: - command: ['pgrep', '-f', 'php bin/console messenger:consume'] - failureThreshold: 40 - periodSeconds: 3 - readinessProbe: - exec: - command: ['pgrep', '-f', 'php bin/console messenger:consume'] - periodSeconds: 3 - livenessProbe: - exec: - command: ['pgrep', '-f', 'php bin/console messenger:consume'] - periodSeconds: 3 - resources: - {{- toYaml .Values.resources | nindent 12 }} volumes: - name: php-socket emptyDir: {} diff --git a/helm/api-platform/templates/fixtures-job.yaml b/helm/api-platform/templates/fixtures-job.yaml index 7c1dbd062..6ae29f4f9 100644 --- a/helm/api-platform/templates/fixtures-job.yaml +++ b/helm/api-platform/templates/fixtures-job.yaml @@ -32,11 +32,6 @@ spec: command: ['/bin/sh', '-c'] args: ['composer install --prefer-dist --no-progress --no-interaction && bin/console doctrine:fixtures:load --no-interaction'] env: - - name: API_ENTRYPOINT_HOST - valueFrom: - configMapKeyRef: - name: {{ include "api-platform.fullname" . }} - key: host - name: OIDC_SERVER_URL valueFrom: configMapKeyRef: diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml index c50cd836b..9085b266d 100644 --- a/helm/api-platform/values.yaml +++ b/helm/api-platform/values.yaml @@ -18,7 +18,6 @@ php: - "10.0.0.0/8" - "172.16.0.0/12" - "192.168.0.0/16" - host: "chart-example.local" pwa: image: diff --git a/pwa/Dockerfile b/pwa/Dockerfile index d5d5c5a4b..91cf2c01b 100644 --- a/pwa/Dockerfile +++ b/pwa/Dockerfile @@ -1,8 +1,15 @@ #syntax=docker/dockerfile:1.4 + + +# Versions +FROM node:18-alpine AS node_upstream + + # Base stage for dev and build -FROM node:18-alpine as builder_base +FROM node_upstream AS base # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +# hadolint ignore=DL3018 RUN apk add --no-cache libc6-compat WORKDIR /srv/app @@ -13,58 +20,55 @@ RUN corepack enable && \ # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during dev and build. -# ENV NEXT_TELEMETRY_DISABLED 1 - - -# Deps stage, preserve dependencies in cache as long as the lockfile isn't changed -FROM builder_base AS deps - -COPY --link pnpm-lock.yaml ./ -RUN pnpm fetch - -COPY --link . . -RUN pnpm install -r --offline +# Delete the following line in case you want to enable telemetry during dev and build. +ENV NEXT_TELEMETRY_DISABLED 1 # Development image -FROM deps as dev +FROM base as dev EXPOSE 3000 ENV PORT 3000 +ENV HOSTNAME localhost + +CMD ["sh", "-c", "pnpm install; pnpm dev"] -CMD ["sh", "-c", "pnpm install -r --offline; pnpm dev"] +FROM base AS builder + +COPY --link pnpm-lock.yaml ./ +RUN pnpm fetch --prod -FROM builder_base AS builder COPY --link . . -COPY --from=deps --link /srv/app/node_modules ./node_modules -RUN pnpm run build +RUN pnpm install --frozen-lockfile --offline --prod && \ + pnpm run build # Production image, copy all the files and run next -FROM node:18-alpine AS prod +FROM node_upstream AS prod + WORKDIR /srv/app ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 +# Delete the following line in case you want to enable telemetry during runtime. +ENV NEXT_TELEMETRY_DISABLED 1 -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN addgroup --system --gid 1001 nodejs; \ + adduser --system --uid 1001 nextjs COPY --from=builder --link /srv/app/public ./public # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --link --chown=nextjs:nodejs /srv/app/.next/standalone ./ -COPY --from=builder --link --chown=nextjs:nodejs /srv/app/.next/static ./.next/static +COPY --from=builder --link --chown=1001:1001 /srv/app/.next/standalone ./ +COPY --from=builder --link --chown=1001:1001 /srv/app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT 3000 +ENV HOSTNAME localhost CMD ["node", "server.js"] diff --git a/pwa/components/admin/Admin.tsx b/pwa/components/admin/Admin.tsx index b46aad3a9..51a614667 100644 --- a/pwa/components/admin/Admin.tsx +++ b/pwa/components/admin/Admin.tsx @@ -1,41 +1,30 @@ import Head from "next/head"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import { - DataProvider, - Layout, - LayoutProps, - localStorageStore, - resolveBrowserLocale, -} from "react-admin"; +import { type Session } from "next-auth"; +import {useContext, useRef, useState} from "react"; +import { type DataProvider, Layout, type LayoutProps, localStorageStore, resolveBrowserLocale } from "react-admin"; +import { signIn, useSession } from "next-auth/react"; +import SyncLoader from "react-spinners/SyncLoader"; import polyglotI18nProvider from "ra-i18n-polyglot"; import englishMessages from "ra-language-english"; import frenchMessages from "ra-language-french"; -import { - fetchHydra, - HydraAdmin, - hydraDataProvider, - OpenApiAdmin -} from "@api-platform/admin"; +import { fetchHydra, HydraAdmin, hydraDataProvider, OpenApiAdmin, ResourceGuesser } from "@api-platform/admin"; import { parseHydraDocumentation } from "@api-platform/api-doc-parser"; -import DocContext from "./DocContext"; -import AppBar from "./AppBar"; -import { ENTRYPOINT } from "../../config/entrypoint"; -import { getSession, signIn, useSession } from "next-auth/react"; +import DocContext from "@/components/admin/DocContext"; +import AppBar from "@/components/admin/AppBar"; +import Menu from "@/components/admin/Menu"; +import { ENTRYPOINT } from "@/config/entrypoint"; +import { List as BooksList } from "@/components/admin/book/List"; +import { List as ReviewsList } from "@/components/admin/review/List"; -const getHeaders = async () => { - const session = await getSession(); - - return { - // @ts-ignore - Authorization: `Bearer ${session?.accessToken}`, - }; -}; - -const apiDocumentationParser = () => async () => { +const apiDocumentationParser = (session: Session) => async () => { try { - // @ts-ignore - return await parseHydraDocumentation(ENTRYPOINT, { headers: getHeaders() }); + return await parseHydraDocumentation(ENTRYPOINT, { + headers: { + // @ts-ignore + Authorization: `Bearer ${session?.accessToken}`, + }, + }); } catch (result) { // @ts-ignore const {api, response, status} = result; @@ -61,9 +50,9 @@ const i18nProvider = polyglotI18nProvider( resolveBrowserLocale(), ); -const MyLayout = (props: React.JSX.IntrinsicAttributes & LayoutProps) => ; +const MyLayout = (props: React.JSX.IntrinsicAttributes & LayoutProps) => ; -const AdminUI = () => { +const AdminUI = ({ session, children }: { session: Session, children?: React.ReactNode | undefined }) => { // @ts-ignore const dataProvider = useRef(undefined); const { docType } = useContext(DocContext); @@ -73,20 +62,24 @@ const AdminUI = () => { entrypoint: ENTRYPOINT, httpClient: (url: URL, options = {}) => fetchHydra(url, { ...options, - // @ts-ignore - headers: getHeaders(), + headers: { + // @ts-ignore + Authorization: `Bearer ${session?.accessToken}`, + }, }), - apiDocumentationParser: apiDocumentationParser(), + apiDocumentationParser: apiDocumentationParser(session), }); - return docType === 'hydra' ? ( + return docType === "hydra" ? ( + > + {!!children && children} + ) : ( { docEntrypoint={`${window.origin}/docs.json`} i18nProvider={i18nProvider} layout={MyLayout} - /> + > + {!!children && children} + ); }; const store = localStorageStore(); - -const AdminWithContext = () => { +const AdminWithContext = ({ session }: { session: Session }) => { const [docType, setDocType] = useState( - store.getItem('docType', 'hydra'), + store.getItem("docType", "hydra"), ); return ( @@ -112,24 +106,22 @@ const AdminWithContext = () => { docType, setDocType, }}> - + + + + ); }; const AdminWithOIDC = () => { // Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488 - const { status } = useSession(); - - useEffect(() => { - if (status === "unauthenticated") { - signIn('keycloak'); - } - }, [status]); + const { data: session, status } = useSession(); - if (status === "loading") return

Loading...

; + if (status === "loading") return ; + if (!session) return signIn("keycloak"); - return ; + return ; }; const Admin = () => ( @@ -138,6 +130,7 @@ const Admin = () => ( API Platform Admin + {/*@ts-ignore*/} ); diff --git a/pwa/components/admin/AppBar.tsx b/pwa/components/admin/AppBar.tsx index 7e6b21b26..37b2d0ba6 100644 --- a/pwa/components/admin/AppBar.tsx +++ b/pwa/components/admin/AppBar.tsx @@ -1,25 +1,17 @@ -import { useContext, useState } from 'react'; -import { - AppBar, - AppBarClasses, - LocalesMenuButton, - ToggleThemeButton, - useAuthProvider, - useStore, -} from 'react-admin'; -import type { AppBarProps } from 'react-admin'; -import { Box, Button, Menu, MenuItem, Typography } from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useContext, useState } from "react"; +import { AppBar, AppBarClasses, useAuthProvider, useStore } from "react-admin"; +import { type AppBarProps } from "react-admin"; +import { Box, Button, Menu, MenuItem, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import DocContext from './DocContext'; -import HydraLogo from './HydraLogo'; -import OpenApiLogo from './OpenApiLogo'; -import Logo from './Logo'; -import { darkTheme, lightTheme } from './themes'; +import DocContext from "@/components/admin/DocContext"; +import HydraLogo from "@/components/admin/HydraLogo"; +import OpenApiLogo from "@/components/admin/OpenApiLogo"; +import Logo from "@/components/admin/Logo"; const DocTypeMenuButton = () => { const [anchorEl, setAnchorEl] = useState(null); - const [, setStoreDocType] = useStore('docType', 'hydra'); + const [, setStoreDocType] = useStore("docType", "hydra"); const { docType, setDocType } = useContext(DocContext); const open = Boolean(anchorEl); @@ -40,11 +32,11 @@ const DocTypeMenuButton = () => {
); @@ -78,20 +70,13 @@ const CustomAppBar = ({ classes, userMenu, ...props }: AppBarProps) => { - - ); }; diff --git a/pwa/components/admin/DocContext.ts b/pwa/components/admin/DocContext.ts index 2b701887d..a9d6bcf31 100644 --- a/pwa/components/admin/DocContext.ts +++ b/pwa/components/admin/DocContext.ts @@ -1,7 +1,7 @@ -import { createContext } from 'react'; +import { createContext } from "react"; const DocContext = createContext({ - docType: 'hydra', + docType: "hydra", setDocType: (_docType: string) => {}, }); diff --git a/pwa/components/admin/HydraLogo.tsx b/pwa/components/admin/HydraLogo.tsx index f109d5ec9..063d2c39a 100644 --- a/pwa/components/admin/HydraLogo.tsx +++ b/pwa/components/admin/HydraLogo.tsx @@ -15,8 +15,8 @@ const HydraLogo = () => ( y1="29.7524" x2="31.9917" y2="29.7524"> - - + + ( y1="36.1528" x2="51.5151" y2="36.1528"> - - - + + + ( y1="3.144" x2="42.1111" y2="60.5359"> - - - + + + ( ( + + }/> + }/> + +); +export default Menu; diff --git a/pwa/components/admin/book/List.tsx b/pwa/components/admin/book/List.tsx new file mode 100644 index 000000000..6f8069024 --- /dev/null +++ b/pwa/components/admin/book/List.tsx @@ -0,0 +1,63 @@ +import { FieldGuesser, type ListGuesserProps } from "@api-platform/admin"; +import { + TextInput, + Pagination, + Datagrid, + type PaginationProps, + useRecordContext, + type UseRecordContextParams, + SelectInput, + Button, + List as ReactAdminList, + EditButton, ShowButtonProps +} from "react-admin"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import slugify from "slugify"; +import {getItemPath} from "@/utils/dataAccess"; + +const ConditionField = (props: UseRecordContextParams) => { + const record = useRecordContext(props); + + // todo translate condition + return record ? {record.condition.replace("https://schema.org/", "")} : null; +}; +ConditionField.defaultProps = { label: "Condition" }; + +const filters = [ + , + , + , +]; + +const PostPagination = (props: PaginationProps) => ; + +const ShowButton = (props: ShowButtonProps) => { + const record = useRecordContext(props); + + return record ? ( + + ) : null; +}; + +export const List = (props: ListGuesserProps) => ( + } exporter={false} title="Books"> + + + + + + + + +); diff --git a/pwa/components/admin/review/List.tsx b/pwa/components/admin/review/List.tsx new file mode 100644 index 000000000..004878ebc --- /dev/null +++ b/pwa/components/admin/review/List.tsx @@ -0,0 +1,20 @@ +import { FieldGuesser, ListGuesser, type ListGuesserProps } from "@api-platform/admin"; +import { useRecordContext, type UseRecordContextParams } from "react-admin"; +import Rating from "@mui/material/Rating"; + +const RatingField = (props: UseRecordContextParams) => { + const record = useRecordContext(props); + + return record ? : null; +}; +RatingField.defaultProps = { label: "Rating" }; + +export const List = (props: ListGuesserProps) => ( + + + + + + + +); diff --git a/pwa/components/admin/themes.ts b/pwa/components/admin/themes.ts deleted file mode 100644 index 26a03407f..000000000 --- a/pwa/components/admin/themes.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { defaultTheme } from 'react-admin'; -import type { RaThemeOptions } from 'react-admin'; - -export const darkTheme: RaThemeOptions = { - ...defaultTheme, - palette: { - ...defaultTheme.palette, - background: { - default: '#424242', - }, - primary: { - contrastText: '#ffffff', - main: '#52c9d4', - light: '#9bf5fe', - dark: '#21a1ae', - }, - secondary: { - // @ts-ignore - ...defaultTheme.palette.secondary, - main: '#51b2bc', - }, - mode: 'dark', - }, - components: { - ...defaultTheme.components, - // @ts-ignore react-admin doesn't add its own components - RaMenuItemLink: { - styleOverrides: { - root: { - borderLeft: '3px solid #000', - '&.RaMenuItemLink-active': { - borderLeft: '3px solid #52c9d4', - }, - }, - }, - }, - }, -}; - -export const lightTheme: RaThemeOptions = { - ...defaultTheme, - palette: { - ...defaultTheme.palette, - primary: { - contrastText: '#ffffff', - main: '#38a9b4', - light: '#74dde7', - dark: '#006a75', - }, - secondary: { - // @ts-ignore - ...defaultTheme.palette.secondary, - main: '#288690', - }, - mode: 'light', - }, - components: { - // @ts-ignore react-admin doesn't add its own components - RaMenuItemLink: { - styleOverrides: { - root: { - borderLeft: '3px solid #fff', - '&.RaMenuItemLink-active': { - borderLeft: '3px solid #38a9b4', - }, - }, - }, - }, - }, -}; diff --git a/pwa/components/book/Filters.tsx b/pwa/components/book/Filters.tsx new file mode 100644 index 000000000..546b89de9 --- /dev/null +++ b/pwa/components/book/Filters.tsx @@ -0,0 +1,128 @@ +import { Formik } from "formik"; +import { type FunctionComponent } from "react"; +import { type UseMutationResult } from "react-query"; +import { Checkbox, FormControlLabel, FormGroup, TextField, Typography } from "@mui/material"; + +import { type FiltersProps } from "@/utils/book"; +import { type FetchError, type FetchResponse } from "@/utils/dataAccess"; +import { type PagedCollection } from "@/types/collection"; +import { type Book } from "@/types/Book"; + +interface Props { + filters: FiltersProps | undefined + mutation: UseMutationResult>> +} + +export const Filters: FunctionComponent = ({ filters, mutation }) => ( + { + mutation.mutate( + values, + { + onSuccess: () => { + setStatus({ + isValid: true, + msg: "List filtered.", + }); + }, + // @ts-ignore + onError: (error: Error | FetchError) => { + setStatus({ + isValid: false, + msg: error.message, + }); + if ("fields" in error) { + setErrors(error.fields); + } + }, + onSettled: () => { + setSubmitting(false); + }, + } + ); + }} + > + {({ + values, + handleChange, + handleSubmit, + submitForm, + }) => ( +
+ + Author + } control={ + { + handleChange(e); + submitForm(); + }} + /> + }/> + + + Title + } control={ + { + handleChange(e); + submitForm(); + }} + /> + }/> + + +
    +

    Condition

    +
  • + } + checked={!!values?.condition?.includes("https://schema.org/NewCondition")} + value="https://schema.org/NewCondition" + onChange={(e) => { + handleChange(e); + submitForm(); + }} + /> +
  • +
  • + } + checked={!!values?.condition?.includes("https://schema.org/DamagedCondition")} + value="https://schema.org/DamagedCondition" + onChange={(e) => { + handleChange(e); + submitForm(); + }} + /> +
  • +
  • + } + checked={!!values?.condition?.includes("https://schema.org/RefurbishedCondition")} + value="https://schema.org/RefurbishedCondition" + onChange={(e) => { + handleChange(e); + submitForm(); + }} + /> +
  • +
  • + } + checked={!!values?.condition?.includes("https://schema.org/UsedCondition")} + value="https://schema.org/UsedCondition" + onChange={(e) => { + handleChange(e); + submitForm(); + }} + /> +
  • +
+
+
+ )} +
+); diff --git a/pwa/components/book/Form.tsx b/pwa/components/book/Form.tsx deleted file mode 100644 index c999181ef..000000000 --- a/pwa/components/book/Form.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { FunctionComponent, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { ArrayHelpers, ErrorMessage, Field, FieldArray, Formik } from "formik"; -import { useMutation } from "react-query"; - -import { fetch, FetchError, FetchResponse } from "../../utils/dataAccess"; -import { Book } from "../../types/Book"; - -interface Props { - book?: Book; -} - -interface SaveParams { - values: Book; -} - -interface DeleteParams { - id: string; -} - -const saveBook = async ({ values }: SaveParams) => - await fetch(!values["@id"] ? "/books" : values["@id"], { - method: !values["@id"] ? "POST" : "PUT", - body: JSON.stringify(values), - }); - -const deleteBook = async (id: string) => - await fetch(id, { method: "DELETE" }); - -export const Form: FunctionComponent = ({ book }) => { - const [, setError] = useState(null); - const router = useRouter(); - - const saveMutation = useMutation< - FetchResponse | undefined, - Error | FetchError, - SaveParams - >((saveParams) => saveBook(saveParams)); - - const deleteMutation = useMutation< - FetchResponse | undefined, - Error | FetchError, - DeleteParams - >(({ id }) => deleteBook(id), { - onSuccess: () => { - router.push("/books"); - }, - onError: (error) => { - setError(`Error when deleting the resource: ${error}`); - console.error(error); - }, - }); - - const handleDelete = () => { - if (!book || !book["@id"]) return; - if (!window.confirm("Are you sure you want to delete this item?")) return; - deleteMutation.mutate({ id: book["@id"] }); - }; - - return ( -
- - {`< Back to list`} - -

- {book ? `Edit Book ${book["@id"]}` : `Create Book`} -

- emb["@id"]) ?? [], - } - : new Book() - } - validate={() => { - const errors = {}; - // add your validation logic here - return errors; - }} - onSubmit={(values, { setSubmitting, setStatus, setErrors }) => { - const isCreation = !values["@id"]; - saveMutation.mutate( - { values }, - { - onSuccess: () => { - setStatus({ - isValid: true, - msg: `Element ${isCreation ? "created" : "updated"}.`, - }); - router.push("/books"); - }, - onError: (error) => { - setStatus({ - isValid: false, - msg: `${error.message}`, - }); - if ("fields" in error) { - setErrors(error.fields); - } - }, - onSettled: () => { - setSubmitting(false); - }, - } - ); - }} - > - {({ - values, - status, - errors, - touched, - handleChange, - handleBlur, - handleSubmit, - isSubmitting, - }) => ( -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- reviews -
- ( -
- {values.reviews && values.reviews.length > 0 ? ( - values.reviews.map((item: any, index: number) => ( -
- - - -
- )) - ) : ( - - )} -
- )} - /> -
- {status && status.msg && ( -
- {status.msg} -
- )} - -
- )} -
-
- {book && ( - - )} -
-
- ); -}; diff --git a/pwa/components/book/Item.tsx b/pwa/components/book/Item.tsx new file mode 100644 index 000000000..f65fdb274 --- /dev/null +++ b/pwa/components/book/Item.tsx @@ -0,0 +1,66 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import { type FunctionComponent, useEffect, useState } from "react"; +import Rating from "@mui/material/Rating"; + +import { type Book } from "@/types/Book"; +import { getItemPath } from "@/utils/dataAccess"; +import { populateBook } from "@/utils/book"; +import { Loading } from "@/components/common/Loading"; + +interface Props { + book: Book +} + +export const Item: FunctionComponent = ({ book }) => { + const [data, setData] = useState(); + const { status } = useSession(); + + useEffect(() => { + if (status === "loading") return; + + (async () => { + try { + const bookData = await populateBook(book); + setData(bookData); + } catch (error) { + console.error(error); + } + })() + }, [book, status]); + + if (!data) { + return ; + } + + return ( +
+
+ {!!data["images"] && ( + {data["title"]} + ) || ( + No cover + )} +
+
+

+ + {data["title"]} + +

+

+ + {data["author"]} + +

+ {!!data["rating"] && ( + + )} +
+
+ ); +}; diff --git a/pwa/components/book/List.tsx b/pwa/components/book/List.tsx index e0effc80b..569306b25 100644 --- a/pwa/components/book/List.tsx +++ b/pwa/components/book/List.tsx @@ -1,111 +1,79 @@ -import { FunctionComponent } from "react"; -import Link from "next/link"; +import { type NextPage } from "next"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useMutation } from "react-query"; +import FilterListOutlinedIcon from "@mui/icons-material/FilterListOutlined"; -import ReferenceLinks from "../common/ReferenceLinks"; -import { getItemPath } from "../../utils/dataAccess"; -import { Book } from "../../types/Book"; +import { Item } from "@/components/book/Item"; +import { Filters } from "@/components/book/Filters"; +import { Pagination } from "@/components/common/Pagination"; +import { type Book } from "@/types/Book"; +import { type PagedCollection } from "@/types/collection"; +import { type FiltersProps, buildUriFromFilters } from "@/utils/book"; +import { type FetchError, type FetchResponse } from "@/utils/dataAccess"; +import { useMercure } from "@/utils/mercure"; interface Props { - books: Book[]; + data: PagedCollection | null + hubURL: string | null + filters: FiltersProps + page: number } -export const List: FunctionComponent = ({ books }) => ( -
-
-

Book List

- - Create - -
- - - - - - - - - - - - - - {books && - books.length !== 0 && - books.map( - (book) => - book["@id"] && ( - - - - - - - - - - - - ) +const getPagePath = (page: number): string => `/books?page=${page}`; + +export const List: NextPage = ({ data, hubURL, filters, page }) => { + const collection = useMercure(data, hubURL); + const router = useRouter(); + + const filtersMutation = useMutation< + FetchResponse> | undefined, + Error | FetchError, + FiltersProps + >(async (filters) => { + router.push(buildUriFromFilters("/books", filters)); + }); + + return ( +
+ + Books Store + +
+ +
+ {!!collection && !!collection["hydra:member"] && ( + <> +

+ + Sort by: + {/*todo move to filters form?*/} + + + {collection["hydra:totalItems"]} book(s) found +

+
+ {collection["hydra:member"].length !== 0 && collection["hydra:member"].map((book) => ( + + ))} +
+ + + ) || ( +

No books found.

)} -
-
idisbntitledescriptionauthorpublicationDatereviews -
- - {book["isbn"]}{book["title"]}{book["description"]}{book["author"]}{book["publicationDate"]?.toLocaleString()} - ({ - href: getItemPath(emb["@id"], "/reviews/[id]"), - name: emb["@id"], - }))} - /> - - - Show - - - - - - - - Edit - - - - - -
-
-); + + + + ); +}; diff --git a/pwa/components/book/PageList.tsx b/pwa/components/book/PageList.tsx deleted file mode 100644 index 177e7075b..000000000 --- a/pwa/components/book/PageList.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { NextComponentType, NextPageContext } from "next"; -import { useRouter } from "next/router"; -import Head from "next/head"; -import { useQuery } from "react-query"; - -import Pagination from "../common/Pagination"; -import { List } from "./List"; -import { PagedCollection } from "../../types/collection"; -import { Book } from "../../types/Book"; -import { fetch, FetchResponse, parsePage } from "../../utils/dataAccess"; -import { useMercure } from "../../utils/mercure"; - -export const getBooksPath = (page?: string | string[] | undefined) => - `/books${typeof page === "string" ? `?page=${page}` : ""}`; -export const getBooks = (page?: string | string[] | undefined) => async () => - await fetch>(getBooksPath(page)); -const getPagePath = (path: string) => `/books/page/${parsePage("books", path)}`; - -export const PageList: NextComponentType = () => { - const { - query: { page }, - } = useRouter(); - const { data: { data: books, hubURL } = { hubURL: null } } = useQuery< - FetchResponse> | undefined - >(getBooksPath(page), getBooks(page)); - const collection = useMercure(books, hubURL); - - if (!collection || !collection["hydra:member"]) return null; - - return ( -
-
- - Book List - -
- - -
- ); -}; diff --git a/pwa/components/book/Show.tsx b/pwa/components/book/Show.tsx index ffd58b395..0e7cd2b00 100644 --- a/pwa/components/book/Show.tsx +++ b/pwa/components/book/Show.tsx @@ -1,190 +1,162 @@ -import { FunctionComponent, useState } from "react"; -import Image from 'next/image'; -import Link from "next/link"; -import { useRouter } from "next/router"; +import { type NextPage } from "next"; import Head from "next/head"; -import { useSession, signIn } from "next-auth/react"; +import Image from "next/image"; +import Link from "next/link"; +import { signIn, type SignInResponse, useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { useMutation } from "react-query"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "@mui/material/Breadcrumbs"; +import Rating from '@mui/material/Rating'; +import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; +import FavoriteIcon from "@mui/icons-material/Favorite"; -import ReferenceLinks from "../common/ReferenceLinks"; -import { fetch, getItemPath } from "../../utils/dataAccess"; -import { Book } from "../../types/Book"; -import SyncLoader from "react-spinners/SyncLoader"; +import { type Book } from "@/types/Book"; +import { useMercure } from "@/utils/mercure"; +import { List as Reviews } from "@/components/review/List"; +import { populateBook } from "@/utils/book"; +import { fetch, type FetchError, type FetchResponse } from "@/utils/dataAccess"; +import { type Bookmark } from "@/types/Bookmark"; +import { type PagedCollection } from "@/types/collection"; +import { Loading } from "@/components/common/Loading"; interface Props { - book: Book; - text: string; + data: Book + hubURL: string | null + page: number +} + +interface BookmarkProps { + book: string } -export const Show: FunctionComponent = ({ book, text }) => { - const [, setBook] = useState(book); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const router = useRouter(); - const { data: session } = useSession(); +const saveBookmark = async (values: BookmarkProps) => + await fetch("/bookmarks", { + method: "POST", + body: JSON.stringify(values), + }); - const handleDelete = async () => { - if (!book["@id"]) return; - if (!window.confirm("Are you sure you want to delete this item?")) return; +const deleteBookmark = async (id: string) => + await fetch(id, { method: "DELETE" }); + +export const Show: NextPage = ({ data, hubURL, page }) => { + const { data: session, status } = useSession(); + const [book, setBook] = useState(); + const [bookmark, setBookmark] = useState(); + const item = useMercure(data, hubURL); + + const bookmarkMutation = useMutation< + Promise | SignInResponse | undefined>, + Error | FetchError, + BookmarkProps + >((data: BookmarkProps) => { + if (!session) { + return signIn('keycloak'); + } - try { - await fetch(book["@id"], { method: "DELETE" }); - router.push("/books"); - } catch (error) { - setError("Error when deleting the resource."); - console.error(error); + if (bookmark) { + return deleteBookmark(bookmark["@id"]); } - }; - const handleGenerateCover = async () => { - if (!book["@id"]) return; + return saveBookmark(data); + }); - try { - // Disable cover to display spinner - delete book["cover"]; + useEffect(() => { + if (status === "loading") return; + + (async () => { + const book = await populateBook(data); setBook(book); - // Display spinner - setLoading(true); - await fetch(`${book["@id"]}/generate-cover`, { method: "PUT" }); - } catch (error) { - setError("Error when generating the book cover."); - console.error(error); - } - }; + })() + }, [data, status]); + + useEffect(() => { + if (status === "loading") return; - if (loading && book["cover"]) { - setLoading(false); - } + (async () => { + try { + const response: FetchResponse> | undefined = await fetch(`/bookmarks?book=${data["@id"]}`); + if (response && response?.data && response.data["hydra:member"]?.length) { + setBookmark(response.data["hydra:member"][0]); + } + } catch (error) { + console.error(error); + setBookmark(undefined); + } + })() + }, [data, status]); return ( -
+
- {`Show Book ${book["@id"]}`} -