├── .editorconfig ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── dependabot.yml └── workflows │ ├── cd.yml │ ├── check.yml │ ├── ci.yml │ ├── cleanup.yml │ ├── deploy.yml │ ├── security.yml │ └── upgrade-api-platform.yml ├── .gitignore ├── .hadolint.yaml ├── LICENSE ├── README.md ├── api ├── .dockerignore ├── .env ├── .env.test ├── .gitignore ├── .php-cs-fixer.dist.php ├── Dockerfile ├── README.md ├── bin │ └── console ├── composer.json ├── composer.lock ├── config │ ├── bootstrap.php │ ├── bundles.php │ ├── packages │ │ ├── api_platform.yaml │ │ ├── cache.yaml │ │ ├── dama_doctrine_test_bundle.yaml │ │ ├── debug.yaml │ │ ├── doctrine.yaml │ │ ├── doctrine_migrations.yaml │ │ ├── framework.yaml │ │ ├── jose.yaml │ │ ├── mercure.yaml │ │ ├── monolog.yaml │ │ ├── nelmio_cors.yaml │ │ ├── routing.yaml │ │ ├── security.yaml │ │ ├── twig.yaml │ │ ├── uid.yaml │ │ ├── validator.yaml │ │ ├── web_profiler.yaml │ │ └── zenstruck_foundry.yaml │ ├── preload.php │ ├── routes.yaml │ ├── routes │ │ ├── api_platform.yaml │ │ ├── attributes.yaml │ │ ├── framework.yaml │ │ ├── security.yaml │ │ └── web_profiler.yaml │ └── services.yaml ├── frankenphp │ ├── Caddyfile │ ├── conf.d │ │ ├── app.dev.ini │ │ ├── app.ini │ │ └── app.prod.ini │ ├── docker-entrypoint.sh │ └── worker.Caddyfile ├── migrations │ ├── .gitignore │ └── Version20230906094949.php ├── phpstan.dist.neon ├── phpunit.xml.dist ├── public │ ├── apple-touch-icon.png │ ├── assets │ │ └── react.production.min.js │ ├── favicon.ico │ └── index.php ├── src │ ├── ApiResource │ │ └── .gitignore │ ├── BookRepository │ │ ├── BookRepositoryInterface.php │ │ ├── ChainBookRepository.php │ │ ├── GutendexBookRepository.php │ │ ├── OpenLibraryBookRepository.php │ │ └── RestrictedBookRepositoryInterface.php │ ├── Command │ │ └── BooksImportCommand.php │ ├── Controller │ │ └── .gitignore │ ├── DataFixtures │ │ ├── AppFixtures.php │ │ ├── Factory │ │ │ ├── BookFactory.php │ │ │ ├── BookmarkFactory.php │ │ │ ├── ReviewFactory.php │ │ │ └── UserFactory.php │ │ ├── Story │ │ │ └── DefaultStory.php │ │ └── books.json │ ├── Doctrine │ │ └── Orm │ │ │ ├── Extension │ │ │ └── BookmarkQueryCollectionExtension.php │ │ │ └── Filter │ │ │ └── NameFilter.php │ ├── Entity │ │ ├── .gitignore │ │ ├── Book.php │ │ ├── Bookmark.php │ │ ├── Parchment.php │ │ ├── Review.php │ │ └── User.php │ ├── Enum │ │ ├── BookCondition.php │ │ └── EnumApiResourceTrait.php │ ├── Kernel.php │ ├── Repository │ │ ├── .gitignore │ │ ├── BookRepository.php │ │ ├── BookmarkRepository.php │ │ ├── ReviewRepository.php │ │ └── UserRepository.php │ ├── Security │ │ ├── Core │ │ │ └── UserProvider.php │ │ ├── Http │ │ │ ├── AccessToken │ │ │ │ └── Oidc │ │ │ │ │ └── OidcDiscoveryTokenHandler.php │ │ │ └── Protection │ │ │ │ ├── ResourceHandlerInterface.php │ │ │ │ └── ResourceResourceHandler.php │ │ └── Voter │ │ │ ├── OidcRoleVoter.php │ │ │ ├── OidcTokenIntrospectRoleVoter.php │ │ │ ├── OidcTokenPermissionVoter.php │ │ │ └── OidcVoter.php │ ├── Serializer │ │ ├── BookNormalizer.php │ │ └── IriTransformerNormalizer.php │ ├── State │ │ └── Processor │ │ │ ├── BookPersistProcessor.php │ │ │ ├── BookRemoveProcessor.php │ │ │ ├── BookmarkPersistProcessor.php │ │ │ ├── ReviewPersistProcessor.php │ │ │ └── ReviewRemoveProcessor.php │ └── Validator │ │ ├── BookUrl.php │ │ ├── BookUrlValidator.php │ │ ├── UniqueUserBook.php │ │ └── UniqueUserBookValidator.php ├── symfony.lock ├── templates │ └── base.html.twig └── tests │ ├── Api │ ├── Admin │ │ ├── BookTest.php │ │ ├── ReviewTest.php │ │ ├── Trait │ │ │ └── UsersDataProviderTrait.php │ │ ├── UserTest.php │ │ └── schemas │ │ │ ├── Book │ │ │ ├── collection.json │ │ │ └── item.json │ │ │ ├── Review │ │ │ ├── collection.json │ │ │ └── item.json │ │ │ └── User │ │ │ ├── collection.json │ │ │ └── item.json │ ├── BookTest.php │ ├── BookmarkTest.php │ ├── ReviewTest.php │ ├── Security │ │ ├── TokenGenerator.php │ │ └── Voter │ │ │ └── Mock │ │ │ ├── KeycloakProtocolOpenIdConnectTokenIntrospectMock.php │ │ │ ├── KeycloakProtocolOpenIdConnectTokenMock.php │ │ │ └── NotImplementedMock.php │ ├── Trait │ │ └── SerializerTrait.php │ └── schemas │ │ ├── Book │ │ ├── collection.json │ │ └── item.json │ │ ├── Bookmark │ │ ├── collection.json │ │ └── item.json │ │ └── Review │ │ ├── collection.json │ │ └── item.json │ ├── Doctrine │ └── Orm │ │ └── Extension │ │ └── BookmarkQueryCollectionExtensionTest.php │ ├── Security │ └── Core │ │ └── UserProviderTest.php │ ├── Serializer │ ├── BookNormalizerTest.php │ └── IriTransformerNormalizerTest.php │ ├── State │ └── Processor │ │ ├── BookPersistProcessorTest.php │ │ ├── BookRemoveProcessorTest.php │ │ ├── BookmarkPersistProcessorTest.php │ │ ├── ReviewPersistProcessorTest.php │ │ └── ReviewRemoveProcessorTest.php │ └── bootstrap.php ├── compose.e2e.yaml ├── compose.override.yaml ├── compose.prod.yaml ├── compose.yaml ├── docs ├── .markdown-lint.yaml └── adr │ ├── 0000-book-fields.md │ ├── 0001-save-book-data-from-bnf-api.md │ ├── 0002-book-reviews-property-as-collection-iri.md │ ├── 0003-live-refresh-on-book-updates.md │ └── 0004-get-user-data.md ├── e2e ├── .gitignore ├── package-lock.json ├── package.json ├── playwright.config.js └── tests │ ├── BookView.spec.ts │ ├── BookmarksList.spec.ts │ ├── BooksList.spec.ts │ ├── GraphQL.spec.ts │ ├── Homepage.spec.ts │ ├── User.spec.ts │ ├── admin │ ├── BookCreate.spec.ts │ ├── BookEdit.spec.ts │ ├── BooksList.spec.ts │ ├── ReviewEdit.spec.ts │ ├── ReviewsList.spec.ts │ ├── User.spec.ts │ ├── pages │ │ ├── AbstractPage.ts │ │ ├── BookPage.ts │ │ ├── ReviewPage.ts │ │ └── UserPage.ts │ └── test.ts │ ├── mocks │ ├── covers.openlibrary.org │ │ └── b │ │ │ └── id │ │ │ └── 4066031-M.jpg │ └── openlibrary.org │ │ ├── books │ │ └── OL2055137M.json │ │ ├── search │ │ ├── Eon-Greg-Bear.json │ │ ├── Foundation-Isaac-Asimov.json │ │ └── Hyperion-Dan-Simmons.json │ │ └── works │ │ └── OL1963268W.json │ ├── pages │ ├── AbstractPage.ts │ ├── BookPage.ts │ ├── BookmarkPage.ts │ └── UserPage.ts │ └── test.ts ├── helm ├── api-platform │ ├── .helmignore │ ├── Chart.lock │ ├── Chart.yaml │ ├── README.md │ ├── keycloak │ │ ├── Dockerfile │ │ ├── certs │ │ │ ├── tls.crt │ │ │ └── tls.pem │ │ ├── config │ │ │ └── realm-demo.json │ │ ├── providers │ │ │ ├── README.md │ │ │ ├── owner-policy.jar │ │ │ └── owner │ │ │ │ ├── META-INF │ │ │ │ └── keycloak-scripts.json │ │ │ │ └── owner-policy.js │ │ └── themes │ │ │ └── api-platform-demo │ │ │ └── login │ │ │ ├── login.ftl │ │ │ └── theme.properties │ ├── templates │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── configmap.yaml │ │ ├── cronjob.yaml │ │ ├── deployment.yaml │ │ ├── fixtures-job.yaml │ │ ├── hpa.yaml │ │ ├── ingress.yaml │ │ ├── pwa-deployment.yaml │ │ ├── pwa-hpa.yaml │ │ ├── pwa-service.yaml │ │ ├── secrets.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tests │ │ │ └── test-connection.yaml │ └── values.yaml ├── skaffold-values.yaml └── skaffold.yaml ├── k6 ├── script.js └── test.sh ├── pwa ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── app │ ├── admin │ │ └── page.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── auth.tsx │ ├── bookmarks │ │ └── page.tsx │ ├── books │ │ ├── [id] │ │ │ └── [slug] │ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ └── providers.tsx ├── components │ ├── admin │ │ ├── Admin.tsx │ │ ├── DocContext.ts │ │ ├── Logo.tsx │ │ ├── authProvider.tsx │ │ ├── book │ │ │ ├── BookForm.tsx │ │ │ ├── BookInput.tsx │ │ │ ├── BooksCreate.tsx │ │ │ ├── BooksEdit.tsx │ │ │ ├── BooksList.tsx │ │ │ ├── ConditionInput.tsx │ │ │ ├── ShowButton.tsx │ │ │ └── index.ts │ │ ├── i18nProvider.ts │ │ ├── layout │ │ │ ├── AppBar.tsx │ │ │ ├── DocTypeMenuButton.tsx │ │ │ ├── HydraLogo.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Logout.tsx │ │ │ ├── Menu.tsx │ │ │ └── OpenApiLogo.tsx │ │ └── review │ │ │ ├── BookField.tsx │ │ │ ├── RatingField.tsx │ │ │ ├── RatingInput.tsx │ │ │ ├── ReviewsEdit.tsx │ │ │ ├── ReviewsList.tsx │ │ │ ├── ReviewsShow.tsx │ │ │ └── index.ts │ ├── book │ │ ├── Filters.tsx │ │ ├── Item.tsx │ │ ├── List.tsx │ │ └── Show.tsx │ ├── bookmark │ │ └── List.tsx │ ├── common │ │ ├── Error.tsx │ │ ├── Header.tsx │ │ ├── Layout.tsx │ │ ├── Loading.tsx │ │ └── Pagination.tsx │ └── review │ │ ├── Form.tsx │ │ ├── Item.tsx │ │ └── List.tsx ├── config │ ├── entrypoint.ts │ └── keycloak.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── postcss.config.mjs ├── public │ ├── api-platform │ │ ├── admin.svg │ │ ├── api.svg │ │ ├── logo_api-platform.svg │ │ ├── logo_tilleuls.svg │ │ ├── mercure.svg │ │ ├── rocket.svg │ │ └── web.svg │ ├── favicon.ico │ └── robots.txt ├── styles │ └── globals.css ├── tailwind.config.js ├── tailwind.config.ts ├── tsconfig.json ├── types │ ├── Book.ts │ ├── Bookmark.ts │ ├── Gutendex │ │ ├── Search.ts │ │ └── SearchDoc.ts │ ├── OpenLibrary │ │ ├── Book.ts │ │ ├── Description.ts │ │ ├── Item.ts │ │ ├── Search.ts │ │ ├── SearchDoc.ts │ │ └── Work.ts │ ├── Review.ts │ ├── Thumbnails.ts │ ├── User.ts │ ├── collection.ts │ └── item.ts └── utils │ ├── book.ts │ ├── dataAccess.ts │ ├── mercure.ts │ └── review.ts └── redocly.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.{js,html,ts,tsx}] 19 | indent_size = 2 20 | 21 | [*.json] 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [*.sh] 28 | indent_style = tab 29 | 30 | [*.xml{,.dist}] 31 | indent_style = space 32 | indent_size = 4 33 | 34 | [*.{yaml,yml}] 35 | trim_trailing_whitespace = false 36 | 37 | [helm/api-platform/**.yaml] 38 | indent_size = 2 39 | 40 | [.github/workflows/*.yml] 41 | indent_size = 2 42 | 43 | [.gitmodules] 44 | indent_style = tab 45 | 46 | [.php_cs{,.dist}] 47 | indent_style = space 48 | indent_size = 4 49 | 50 | [composer.json] 51 | indent_size = 4 52 | 53 | [{,docker-}compose{,.*}.{yaml,yml}] 54 | indent_style = space 55 | indent_size = 2 56 | 57 | [{,*.*}Dockerfile] 58 | indent_style = tab 59 | 60 | [{,*.*}Caddyfile] 61 | indent_style = tab 62 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.conf text eol=lf 4 | *.html text eol=lf 5 | *.ini text eol=lf 6 | *.js text eol=lf 7 | *.json text eol=lf 8 | *.md text eol=lf 9 | *.php text eol=lf 10 | *.sh text eol=lf 11 | *.yaml text eol=lf 12 | *.yml text eol=lf 13 | bin/console text eol=lf 14 | composer.lock text eol=lf merge=ours 15 | pnpm-lock.yaml text eol=lf merge=ours 16 | 17 | *.ico binary 18 | *.png binary 19 | 20 | update-deps.sh export-ignore 21 | .github/CONTRIBUTING.md 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: github-actions 6 | directory: / 7 | schedule: 8 | interval: monthly 9 | - package-ecosystem: composer 10 | directory: /api 11 | schedule: 12 | interval: daily 13 | labels: 14 | - dependencies 15 | open-pull-requests-limit: 0 # open Pull Requests for security updates only 16 | - package-ecosystem: npm 17 | directory: /pwa 18 | schedule: 19 | interval: daily 20 | labels: 21 | - dependencies 22 | open-pull-requests-limit: 0 # open Pull Requests for security updates only 23 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-workflow.json 2 | name: Check 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | url: 8 | type: string 9 | description: URL 10 | 11 | jobs: 12 | check: 13 | name: Check 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: 'read' 17 | id-token: 'write' 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | - 23 | name: Run local k6 test 24 | uses: grafana/k6-action@v0.3.1 25 | with: 26 | filename: k6/script.js 27 | flags: --out json=results.json 28 | env: 29 | TARGET: ${{ inputs.url }} 30 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-workflow.json 2 | name: Cleanup 3 | 4 | on: 5 | pull_request: 6 | types: [ closed ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | GKE_CLUSTER: api-platform-demo 14 | GCE_ZONE: europe-west1-c 15 | 16 | jobs: 17 | cleanup: 18 | name: Uninstall K8s Release for Closed Pull Request 19 | if: ${{ github.repository == 'api-platform/demo' }} 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: 'read' 23 | id-token: 'write' 24 | steps: 25 | - 26 | name: Auth gcloud 27 | uses: google-github-actions/auth@v2 28 | with: 29 | credentials_json: ${{ secrets.GKE_SA_KEY }} 30 | - 31 | name: Setup gcloud 32 | uses: google-github-actions/setup-gcloud@v2 33 | with: 34 | project_id: ${{ secrets.GKE_PROJECT }} 35 | - 36 | name: Configure gcloud 37 | run: | 38 | gcloud components install gke-gcloud-auth-plugin 39 | gcloud --quiet auth configure-docker 40 | gcloud container clusters get-credentials ${{ env.GKE_CLUSTER }} --zone ${{ env.GCE_ZONE }} 41 | - 42 | name: Check for existing namespace 43 | id: k8s-namespace 44 | run: echo "namespace=$(kubectl get namespace pr-${{ github.event.number }} | tr -d '\n' 2> /dev/null)" >> $GITHUB_OUTPUT 45 | - 46 | name: Uninstall release 47 | if: steps.k8s-namespace.outputs.namespace != '' 48 | run: kubectl delete namespace pr-${{ github.event.number }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /helm/api-platform/charts/* 3 | !/helm/api-platform/charts/.gitignore 4 | -------------------------------------------------------------------------------- /.hadolint.yaml: -------------------------------------------------------------------------------- 1 | ignored: 2 | - DL3008 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present Kévin Dunglas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/*.md 3 | **/*.php~ 4 | **/*.dist.php 5 | **/*.dist 6 | **/*.cache 7 | **/._* 8 | **/.dockerignore 9 | **/.DS_Store 10 | **/.git/ 11 | **/.gitattributes 12 | **/.gitignore 13 | **/.gitmodules 14 | **/compose.*.yaml 15 | **/compose.*.yml 16 | **/compose.yaml 17 | **/compose.yml 18 | **/docker-compose.*.yaml 19 | **/docker-compose.*.yml 20 | **/docker-compose.yaml 21 | **/docker-compose.yml 22 | **/Dockerfile 23 | **/Thumbs.db 24 | .github/ 25 | docs/ 26 | public/bundles/ 27 | tests/ 28 | var/ 29 | vendor/ 30 | .editorconfig 31 | .env.*.local 32 | .env.local 33 | .env.local.php 34 | .env.test 35 | -------------------------------------------------------------------------------- /api/.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | OIDC_JWK='{"kty": "EC","d": "cT3_vKHaGOAhhmzR0Jbi1ko40dNtpjtaiWzm_7VNwLA","use": "sig","crv": "P-256","x": "n6PnJPqNK5nP-ymwwsOIqZvjiCKFNzRyqWA8KNyBsDo","y": "bQSmMlDXOmtgyS1rhsKUmqlxq-8Kw0Iw9t50cSloTMM","alg": "ES256"}' 6 | 7 | # API Platform distribution 8 | TRUSTED_HOSTS=^example\.com|localhost$ 9 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /docker/db/data 2 | 3 | ###> symfony/framework-bundle ### 4 | /.env.local 5 | /.env.local.php 6 | /.env.*.local 7 | /config/secrets/prod/prod.decrypt.private.php 8 | /public/bundles/ 9 | /var/ 10 | /vendor/ 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> friendsofphp/php-cs-fixer ### 14 | /.php-cs-fixer.php 15 | /.php-cs-fixer.cache 16 | ###< friendsofphp/php-cs-fixer ### 17 | 18 | ###> phpstan/phpstan ### 19 | phpstan.neon 20 | ###< phpstan/phpstan ### 21 | 22 | ###> phpunit/phpunit ### 23 | /phpunit.xml 24 | .phpunit.result.cache 25 | ###< phpunit/phpunit ### 26 | -------------------------------------------------------------------------------- /api/.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRiskyAllowed(true) 10 | ->setRules([ 11 | '@PhpCsFixer' => true, 12 | '@PhpCsFixer:risky' => true, 13 | '@Symfony' => true, 14 | '@Symfony:risky' => true, 15 | 'declare_strict_types' => true, 16 | 'php_unit_data_provider_name' => false, 17 | 'php_unit_data_provider_return_type' => true, 18 | 'php_unit_data_provider_static' => true, 19 | 'php_unit_dedicate_assert' => true, 20 | 'php_unit_dedicate_assert_internal_type' => true, 21 | 'php_unit_expectation' => true, 22 | 'php_unit_fqcn_annotation' => true, 23 | 'php_unit_internal_class' => false, 24 | 'php_unit_method_casing' => true, 25 | 'php_unit_mock_short_will_return' => true, 26 | 'php_unit_namespaced' => true, 27 | 'php_unit_no_expectation_annotation' => true, 28 | 'php_unit_set_up_tear_down_visibility' => true, 29 | 'php_unit_strict' => false, 30 | 'php_unit_test_annotation' => ['style' => 'annotation'], 31 | 'php_unit_test_case_static_method_calls' => false, 32 | 'php_unit_test_class_requires_covers' => false, 33 | 'phpdoc_to_comment' => false, 34 | 'void_return' => true, 35 | 'concat_space' => ['spacing' => 'one'], 36 | 'nullable_type_declaration' => true, 37 | ]) 38 | ->setFinder($finder) 39 | ; 40 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | The API will be here. 4 | 5 | Refer to the [Getting Started Guide](https://api-platform.com/docs/distribution) for more information. 6 | -------------------------------------------------------------------------------- /api/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =1.2) 15 | if (is_array($env = @include dirname(__DIR__) . '/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 16 | (new Dotenv())->usePutenv(false)->populate($env); 17 | } else { 18 | // load all the .env files 19 | (new Dotenv())->usePutenv(false)->loadEnv(dirname(__DIR__) . '/.env'); 20 | } 21 | 22 | $_SERVER += $_ENV; 23 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 24 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 25 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], \FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 26 | -------------------------------------------------------------------------------- /api/config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 7 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 8 | Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], 9 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 10 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 11 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 12 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 13 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 14 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 15 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 16 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 17 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 18 | DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], 19 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true], 20 | Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['all' => true], 21 | Jose\Bundle\JoseFramework\JoseFrameworkBundle::class => ['all' => true], 22 | ]; 23 | -------------------------------------------------------------------------------- /api/config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /api/config/packages/dama_doctrine_test_bundle.yaml: -------------------------------------------------------------------------------- 1 | when@test: 2 | dama_doctrine_test: 3 | enable_static_connection: true 4 | enable_static_meta_data_cache: true 5 | enable_static_query_cache: true 6 | -------------------------------------------------------------------------------- /api/config/packages/debug.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | debug: 3 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 4 | # See the "server:dump" command to start a new server. 5 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 6 | -------------------------------------------------------------------------------- /api/config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '16' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | use_savepoints: true 11 | orm: 12 | auto_generate_proxy_classes: true 13 | enable_lazy_ghost_objects: true 14 | report_fields_where_declared: true 15 | validate_xml_mapping: true 16 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 17 | identity_generation_preferences: 18 | Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity 19 | auto_mapping: true 20 | mappings: 21 | App: 22 | type: attribute 23 | is_bundle: false 24 | dir: '%kernel.project_dir%/src/Entity' 25 | prefix: 'App\Entity' 26 | alias: App 27 | controller_resolver: 28 | auto_mapping: false 29 | 30 | when@test: 31 | doctrine: 32 | dbal: 33 | # "TEST_TOKEN" is typically set by ParaTest 34 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 35 | 36 | when@prod: 37 | doctrine: 38 | orm: 39 | auto_generate_proxy_classes: false 40 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 41 | query_cache_driver: 42 | type: pool 43 | pool: doctrine.system_cache_pool 44 | result_cache_driver: 45 | type: pool 46 | pool: doctrine.result_cache_pool 47 | 48 | framework: 49 | cache: 50 | pools: 51 | doctrine.result_cache_pool: 52 | adapter: cache.app 53 | doctrine.system_cache_pool: 54 | adapter: cache.system 55 | -------------------------------------------------------------------------------- /api/config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: false 7 | -------------------------------------------------------------------------------- /api/config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | http_method_override: false 6 | handle_all_throwables: true 7 | 8 | trusted_proxies: '%env(TRUSTED_PROXIES)%' 9 | #trusted_hosts: '%env(TRUSTED_HOSTS)%' 10 | # See https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#headers 11 | trusted_headers: [ 'x-forwarded-for', 'x-forwarded-proto' ] 12 | 13 | # Note that the session will be started ONLY if you read or write from it. 14 | #session: true 15 | 16 | #esi: true 17 | #fragments: true 18 | 19 | http_client: 20 | scoped_clients: 21 | # use scoped client to ease mock on functional tests 22 | security.authorization.client: 23 | base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/' 24 | open_library.client: 25 | base_uri: 'https://openlibrary.org/' 26 | gutendex.client: 27 | base_uri: 'https://gutendex.com/' 28 | 29 | when@test: 30 | framework: 31 | test: true 32 | #session: 33 | # storage_factory_id: session.storage.factory.mock_file 34 | -------------------------------------------------------------------------------- /api/config/packages/jose.yaml: -------------------------------------------------------------------------------- 1 | jose: 2 | jws: 3 | serializers: 4 | oidc: 5 | serializers: ['jws_compact'] 6 | is_public: true 7 | loaders: 8 | oidc: 9 | serializers: ['jws_compact'] 10 | signature_algorithms: ['HS256', 'RS256', 'ES256'] 11 | header_checkers: ['alg', 'iat', 'nbf', 'exp', 'aud', 'iss'] 12 | is_public: true 13 | checkers: 14 | claims: 15 | oidc: 16 | is_public: true 17 | claims: ['iat', 'nbf', 'exp'] 18 | headers: 19 | oidc: 20 | is_public: true 21 | headers: ['alg', 'iss', 'aud'] 22 | 23 | services: 24 | _defaults: 25 | autowire: true 26 | autoconfigure: true 27 | 28 | Jose\Component\Checker\AlgorithmChecker: 29 | arguments: 30 | $supportedAlgorithms: ['HS256', 'RS256', 'ES256'] 31 | tags: 32 | - name: 'jose.checker.header' 33 | alias: 'alg' 34 | Jose\Component\Checker\AudienceChecker: 35 | arguments: 36 | $audience: '%env(OIDC_AUD)%' 37 | tags: 38 | - name: 'jose.checker.header' 39 | alias: 'aud' 40 | Jose\Component\Checker\IssuerChecker: 41 | arguments: 42 | $issuers: ['%env(OIDC_SERVER_URL)%'] 43 | tags: 44 | - name: 'jose.checker.header' 45 | alias: 'iss' 46 | 47 | when@test: 48 | jose: 49 | jws: 50 | builders: 51 | oidc: 52 | signature_algorithms: ['HS256', 'RS256', 'ES256'] 53 | is_public: true 54 | -------------------------------------------------------------------------------- /api/config/packages/mercure.yaml: -------------------------------------------------------------------------------- 1 | mercure: 2 | hubs: 3 | default: 4 | url: '%env(MERCURE_URL)%' 5 | public_url: '%env(MERCURE_PUBLIC_URL)%' 6 | jwt: 7 | secret: '%env(MERCURE_JWT_SECRET)%' 8 | publish: '*' 9 | -------------------------------------------------------------------------------- /api/config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | formatter: monolog.formatter.json 63 | -------------------------------------------------------------------------------- /api/config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['Content-Type', 'Authorization', 'Preload', 'Fields'] 7 | expose_headers: ['Link'] 8 | max_age: 3600 9 | paths: 10 | '^/': null 11 | -------------------------------------------------------------------------------- /api/config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 4 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 5 | #default_uri: http://localhost 6 | 7 | when@prod: 8 | framework: 9 | router: 10 | strict_requirements: null 11 | -------------------------------------------------------------------------------- /api/config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 3 | providers: 4 | app_user_provider: 5 | id: 'App\Security\Core\UserProvider' 6 | firewalls: 7 | dev: 8 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 9 | security: false 10 | main: 11 | lazy: true 12 | provider: app_user_provider 13 | stateless: true 14 | 15 | # activate different ways to authenticate 16 | # https://symfony.com/doc/current/security.html#the-firewall 17 | 18 | # https://symfony.com/doc/current/security/impersonating_user.html 19 | # switch_user: true 20 | 21 | # Easy way to control access for large sections of your site 22 | # Note: Only the *first* access control that matches will be used 23 | access_control: 24 | - { path: ^/profile, roles: IS_AUTHENTICATED_FULLY } 25 | 26 | when@prod: &prod 27 | security: 28 | firewalls: 29 | main: 30 | access_token: 31 | token_handler: App\Security\Http\AccessToken\Oidc\OidcDiscoveryTokenHandler 32 | # todo support Discovery in Symfony 33 | # oidc: 34 | # claim: 'email' 35 | # base_uri: '%env(OIDC_SERVER_URL)%' 36 | # audience: '%env(OIDC_AUD)%' 37 | # cache: '@cache.app' # default 38 | # cache_ttl: 600 # default 39 | 40 | when@dev: *prod 41 | 42 | when@test: 43 | security: 44 | firewalls: 45 | main: 46 | access_token: 47 | token_handler: 48 | oidc: 49 | claim: 'email' 50 | audience: '%env(OIDC_AUD)%' 51 | issuers: [ '%env(OIDC_SERVER_URL)%' ] 52 | algorithm: 'ES256' 53 | keyset: '{"keys":[%env(OIDC_JWK)%]}' 54 | -------------------------------------------------------------------------------- /api/config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | file_name_pattern: '*.twig' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /api/config/packages/uid.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | uid: 3 | default_uuid_version: 7 4 | time_based_uuid_version: 7 5 | -------------------------------------------------------------------------------- /api/config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | # Enables validator auto-mapping support. 4 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 5 | auto_mapping: 6 | # App\Entity\: [] 7 | 8 | when@test: 9 | framework: 10 | validation: 11 | not_compromised_password: false 12 | -------------------------------------------------------------------------------- /api/config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /api/config/packages/zenstruck_foundry.yaml: -------------------------------------------------------------------------------- 1 | # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration 2 | zenstruck_foundry: 3 | orm: 4 | reset: 5 | entity_managers: [ 'default' ] 6 | -------------------------------------------------------------------------------- /api/config/preload.php: -------------------------------------------------------------------------------- 1 | /dev/null)" ]; then 6 | composer install --prefer-dist --no-progress --no-interaction 7 | fi 8 | 9 | if grep -q ^DATABASE_URL= .env; then 10 | echo "Waiting for database to be ready..." 11 | ATTEMPTS_LEFT_TO_REACH_DATABASE=60 12 | until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do 13 | if [ $? -eq 255 ]; then 14 | # If the Doctrine command exits with 255, an unrecoverable error occurred 15 | ATTEMPTS_LEFT_TO_REACH_DATABASE=0 16 | break 17 | fi 18 | sleep 1 19 | ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) 20 | echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." 21 | done 22 | 23 | if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then 24 | echo "The database is not up or not reachable:" 25 | echo "$DATABASE_ERROR" 26 | exit 1 27 | else 28 | echo "The database is now ready and reachable" 29 | fi 30 | 31 | if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then 32 | php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing 33 | fi 34 | fi 35 | 36 | setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 37 | setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 38 | fi 39 | 40 | exec docker-php-entrypoint "$@" 41 | -------------------------------------------------------------------------------- /api/frankenphp/worker.Caddyfile: -------------------------------------------------------------------------------- 1 | worker { 2 | file ./public/index.php 3 | env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime 4 | } 5 | -------------------------------------------------------------------------------- /api/migrations/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/api/migrations/.gitignore -------------------------------------------------------------------------------- /api/phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - bin/ 5 | - config/ 6 | - public/ 7 | - src/ 8 | excludePaths: 9 | - src/DataFixtures/ 10 | symfony: 11 | containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml 12 | constantHassers: false 13 | scanDirectories: 14 | - var/cache/dev/Symfony/Config 15 | bootstrapFiles: 16 | - vendor/autoload.php 17 | - src/Kernel.php 18 | ignoreErrors: 19 | - message: '#no (?:return|value) type specified in iterable type array\.#' 20 | -------------------------------------------------------------------------------- /api/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | tests 31 | tests/Api 32 | 33 | 34 | tests/Api 35 | 36 | 37 | 38 | 39 | 40 | src 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /api/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/api/public/apple-touch-icon.png -------------------------------------------------------------------------------- /api/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/api/public/favicon.ico -------------------------------------------------------------------------------- /api/public/index.php: -------------------------------------------------------------------------------- 1 | $repositories */ 15 | public function __construct( 16 | #[AutowireIterator(tag: RestrictedBookRepositoryInterface::TAG)] 17 | private iterable $repositories, 18 | ) { 19 | } 20 | 21 | public function find(string $url): ?Book 22 | { 23 | foreach ($this->repositories as $repository) { 24 | if ($repository->supports($url)) { 25 | return $repository->find($url); 26 | } 27 | } 28 | 29 | return null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/src/BookRepository/GutendexBookRepository.php: -------------------------------------------------------------------------------- 1 | ['Accept' => 'application/json']]; 25 | $response = $this->gutendexClient->request('GET', $url, $options); 26 | if (200 !== $response->getStatusCode()) { 27 | return null; 28 | } 29 | 30 | $book = new Book(); 31 | 32 | $data = $response->toArray(); 33 | $book->title = $data['title']; 34 | $book->author = $data['authors'][0]['name'] ?? null; 35 | 36 | return $book; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/src/BookRepository/OpenLibraryBookRepository.php: -------------------------------------------------------------------------------- 1 | ['Accept' => 'application/json']]; 25 | $response = $this->openLibraryClient->request('GET', $url, $options); 26 | if (200 !== $response->getStatusCode()) { 27 | return null; 28 | } 29 | 30 | $book = new Book(); 31 | 32 | $data = $response->toArray(); 33 | $book->title = $data['title']; 34 | 35 | $book->author = null; 36 | if (isset($data['authors'][0]['key'])) { 37 | $authorResponse = $this->openLibraryClient->request('GET', $data['authors'][0]['key'] . '.json', $options); 38 | $author = $authorResponse->toArray(); 39 | if (isset($author['name'])) { 40 | $book->author = $author['name']; 41 | } 42 | } 43 | 44 | return $book; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/src/BookRepository/RestrictedBookRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | getName() 28 | || !$user = $this->security->getUser() 29 | ) { 30 | return; 31 | } 32 | 33 | $queryBuilder 34 | ->andWhere(\sprintf('%s.user = :user', $queryBuilder->getRootAliases()[0])) 35 | ->setParameter('user', $user) 36 | ; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/src/Entity/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/api/src/Entity/.gitignore -------------------------------------------------------------------------------- /api/src/Entity/Parchment.php: -------------------------------------------------------------------------------- 1 | id; 35 | } 36 | 37 | /** 38 | * The title of the book. 39 | */ 40 | #[Assert\NotBlank(allowNull: false)] 41 | #[ORM\Column] 42 | public string $title; 43 | 44 | /** 45 | * A description of the item. 46 | */ 47 | #[Assert\NotBlank(allowNull: false)] 48 | #[ORM\Column] 49 | public string $description; 50 | } 51 | -------------------------------------------------------------------------------- /api/src/Enum/BookCondition.php: -------------------------------------------------------------------------------- 1 | name; 15 | } 16 | 17 | #[Groups('Enum:read')] 18 | public function getValue(): string 19 | { 20 | return $this->value; 21 | } 22 | 23 | public static function getCases(): array 24 | { 25 | return self::cases(); 26 | } 27 | 28 | public static function getCase(Operation $operation, array $uriVariables): ?static 29 | { 30 | $name = $uriVariables['id'] ?? null; 31 | 32 | return self::tryFrom($name); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/src/Kernel.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @method Book|null find($id, $lockMode = null, $lockVersion = null) 15 | * @method Book|null findOneBy(array $criteria, array $orderBy = null) 16 | * @method Book[] findAll() 17 | * @method Book[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 18 | */ 19 | class BookRepository extends ServiceEntityRepository 20 | { 21 | public function __construct(ManagerRegistry $registry) 22 | { 23 | parent::__construct($registry, Book::class); 24 | } 25 | 26 | public function save(Book $entity, bool $flush = false): void 27 | { 28 | $this->getEntityManager()->persist($entity); 29 | 30 | if ($flush) { 31 | $this->getEntityManager()->flush(); 32 | } 33 | } 34 | 35 | public function remove(Book $entity, bool $flush = false): void 36 | { 37 | $this->getEntityManager()->remove($entity); 38 | 39 | if ($flush) { 40 | $this->getEntityManager()->flush(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/Repository/BookmarkRepository.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @method Bookmark|null find($id, $lockMode = null, $lockVersion = null) 15 | * @method Bookmark|null findOneBy(array $criteria, array $orderBy = null) 16 | * @method Bookmark[] findAll() 17 | * @method Bookmark[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 18 | */ 19 | class BookmarkRepository extends ServiceEntityRepository 20 | { 21 | public function __construct(ManagerRegistry $registry) 22 | { 23 | parent::__construct($registry, Bookmark::class); 24 | } 25 | 26 | public function save(Bookmark $entity, bool $flush = false): void 27 | { 28 | $this->getEntityManager()->persist($entity); 29 | 30 | if ($flush) { 31 | $this->getEntityManager()->flush(); 32 | } 33 | } 34 | 35 | public function remove(Bookmark $entity, bool $flush = false): void 36 | { 37 | $this->getEntityManager()->remove($entity); 38 | 39 | if ($flush) { 40 | $this->getEntityManager()->flush(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/Repository/ReviewRepository.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @method Review|null find($id, $lockMode = null, $lockVersion = null) 16 | * @method Review|null findOneBy(array $criteria, array $orderBy = null) 17 | * @method Review[] findAll() 18 | * @method Review[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 19 | */ 20 | class ReviewRepository extends ServiceEntityRepository 21 | { 22 | public function __construct(ManagerRegistry $registry) 23 | { 24 | parent::__construct($registry, Review::class); 25 | } 26 | 27 | public function getAverageRating(Book $book): ?int 28 | { 29 | $rating = $this->createQueryBuilder('r') 30 | ->select('AVG(r.rating)') 31 | ->where('r.book = :book')->setParameter('book', $book) 32 | ->getQuery()->getSingleScalarResult() 33 | ; 34 | 35 | return $rating ? (int) $rating : null; 36 | } 37 | 38 | public function save(Review $entity, bool $flush = false): void 39 | { 40 | $this->getEntityManager()->persist($entity); 41 | 42 | if ($flush) { 43 | $this->getEntityManager()->flush(); 44 | } 45 | } 46 | 47 | public function remove(Review $entity, bool $flush = false): void 48 | { 49 | $this->getEntityManager()->remove($entity); 50 | 51 | if ($flush) { 52 | $this->getEntityManager()->flush(); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /api/src/Repository/UserRepository.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @method User|null find($id, $lockMode = null, $lockVersion = null) 15 | * @method User|null findOneBy(array $criteria, array $orderBy = null) 16 | * @method User[] findAll() 17 | * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 18 | */ 19 | class UserRepository extends ServiceEntityRepository 20 | { 21 | public function __construct(ManagerRegistry $registry) 22 | { 23 | parent::__construct($registry, User::class); 24 | } 25 | 26 | public function save(User $entity, bool $flush = false): void 27 | { 28 | $this->getEntityManager()->persist($entity); 29 | 30 | if ($flush) { 31 | $this->getEntityManager()->flush(); 32 | } 33 | } 34 | 35 | public function remove(User $entity, bool $flush = false): void 36 | { 37 | $this->getEntityManager()->remove($entity); 38 | 39 | if ($flush) { 40 | $this->getEntityManager()->flush(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/Security/Core/UserProvider.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final readonly class UserProvider implements AttributesBasedUserProviderInterface 18 | { 19 | public function __construct(private ManagerRegistry $registry, private UserRepository $repository) 20 | { 21 | } 22 | 23 | public function refreshUser(UserInterface $user): UserInterface 24 | { 25 | $manager = $this->registry->getManagerForClass($user::class); 26 | if (!$manager) { 27 | throw new UnsupportedUserException(\sprintf('User class "%s" not supported.', $user::class)); 28 | } 29 | 30 | $manager->refresh($user); 31 | 32 | return $user; 33 | } 34 | 35 | public function supportsClass(string $class): bool 36 | { 37 | return User::class === $class; 38 | } 39 | 40 | /** 41 | * Create or update User on login. 42 | */ 43 | public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface 44 | { 45 | $user = $this->repository->findOneBy(['email' => $identifier]) ?: new User(); 46 | $user->email = $identifier; 47 | 48 | if (!isset($attributes['given_name'])) { 49 | throw new UnsupportedUserException('Property "given_name" is missing in token attributes.'); 50 | } 51 | $user->firstName = $attributes['given_name']; 52 | 53 | if (!isset($attributes['family_name'])) { 54 | throw new UnsupportedUserException('Property "family_name" is missing in token attributes.'); 55 | } 56 | $user->lastName = $attributes['family_name']; 57 | 58 | $this->repository->save($user); 59 | 60 | return $user; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/src/Security/Http/Protection/ResourceHandlerInterface.php: -------------------------------------------------------------------------------- 1 | getUser() instanceof UserInterface) { 44 | return false; 45 | } 46 | 47 | $accessToken = $this->getToken(); 48 | if (!$accessToken) { 49 | return false; 50 | } 51 | 52 | // OIDC server doesn't seem to answer: check roles in token (if present) 53 | $jws = $this->jwsSerializerManager->unserialize($accessToken); 54 | $claims = json_decode($jws->getPayload(), true); 55 | $roles = array_map(static fn (string $role): string => strtolower($role), $claims['realm_access']['roles'] ?? []); 56 | 57 | return \in_array(strtolower(substr($attribute, 5)), $roles, true); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/src/Security/Voter/OidcVoter.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | abstract class OidcVoter extends Voter 17 | { 18 | public function __construct( 19 | private readonly RequestStack $requestStack, 20 | #[Autowire('@security.access_token_extractor.header')] 21 | private readonly AccessTokenExtractorInterface $accessTokenExtractor, 22 | ) { 23 | } 24 | 25 | /** 26 | * @throws TokenNotFoundException 27 | */ 28 | protected function getToken(): string 29 | { 30 | $request = $this->requestStack->getCurrentRequest(); 31 | 32 | // user is authenticated, its token should be valid (validated through AccessTokenAuthenticator) 33 | $accessToken = $this->accessTokenExtractor->extractAccessToken($request); 34 | if (!$accessToken) { 35 | throw new TokenNotFoundException(); 36 | } 37 | 38 | return $accessToken; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/src/Serializer/BookNormalizer.php: -------------------------------------------------------------------------------- 1 | rating = $this->repository->getAverageRating($object); 34 | 35 | return $this->normalizer->normalize($object, $format, [self::class => true] + $context); 36 | } 37 | 38 | public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 39 | { 40 | return $data instanceof Book && !isset($context[self::class]); 41 | } 42 | 43 | public function getSupportedTypes(?string $format): array 44 | { 45 | return [ 46 | Book::class => false, 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/src/State/Processor/BookPersistProcessor.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final readonly class BookPersistProcessor implements ProcessorInterface 19 | { 20 | /** 21 | * @param PersistProcessor $persistProcessor 22 | */ 23 | public function __construct( 24 | #[Autowire(service: PersistProcessor::class)] 25 | private ProcessorInterface $persistProcessor, 26 | private BookRepositoryInterface $bookRepository, 27 | ) { 28 | } 29 | 30 | /** 31 | * @param Book $data 32 | */ 33 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book 34 | { 35 | $book = $this->bookRepository->find($data->book); 36 | 37 | // this should never happen 38 | if (!$book instanceof Book) { 39 | throw new NotFoundHttpException(); 40 | } 41 | 42 | $data->title = $book->title; 43 | $data->author = $book->author; 44 | 45 | // save entity 46 | return $this->persistProcessor->process($data, $operation, $uriVariables, $context); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /api/src/State/Processor/BookRemoveProcessor.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final readonly class BookRemoveProcessor implements ProcessorInterface 17 | { 18 | /** 19 | * @param RemoveProcessor $removeProcessor 20 | */ 21 | public function __construct( 22 | #[Autowire(service: RemoveProcessor::class)] 23 | private ProcessorInterface $removeProcessor, 24 | ) { 25 | } 26 | 27 | /** 28 | * @param Book $data 29 | */ 30 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void 31 | { 32 | // remove entity 33 | $this->removeProcessor->process($data, $operation, $uriVariables, $context); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/src/State/Processor/BookmarkPersistProcessor.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final readonly class BookmarkPersistProcessor implements ProcessorInterface 19 | { 20 | /** 21 | * @param PersistProcessor $persistProcessor 22 | */ 23 | public function __construct( 24 | #[Autowire(service: PersistProcessor::class)] 25 | private ProcessorInterface $persistProcessor, 26 | private Security $security, 27 | private ClockInterface $clock, 28 | ) { 29 | } 30 | 31 | /** 32 | * @param Bookmark $data 33 | */ 34 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Bookmark 35 | { 36 | /** @phpstan-ignore-next-line */ 37 | $data->user = $this->security->getUser(); 38 | $data->bookmarkedAt = $this->clock->now(); 39 | 40 | // save entity 41 | return $this->persistProcessor->process($data, $operation, $uriVariables, $context); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/State/Processor/ReviewRemoveProcessor.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final readonly class ReviewRemoveProcessor implements ProcessorInterface 18 | { 19 | /** 20 | * @param RemoveProcessor $removeProcessor 21 | */ 22 | public function __construct( 23 | #[Autowire(service: RemoveProcessor::class)] 24 | private ProcessorInterface $removeProcessor, 25 | private ResourceHandlerInterface $resourceHandler, 26 | ) { 27 | } 28 | 29 | /** 30 | * @param Review $data 31 | */ 32 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void 33 | { 34 | $object = clone $data; 35 | 36 | // remove entity 37 | $this->removeProcessor->process($data, $operation, $uriVariables, $context); 38 | 39 | // project specification: only delete resource on OIDC server for known users (john.doe and chuck.norris) 40 | if (\in_array($object->user->email, ['john.doe@example.com', 'chuck.norris@example.com'], true)) { 41 | $this->resourceHandler->delete($object, $object->user, [ 42 | 'operation_name' => '/books/{bookId}/reviews/{id}{._format}', 43 | ]); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/src/Validator/BookUrl.php: -------------------------------------------------------------------------------- 1 | message = $message ?? $this->message; 19 | } 20 | 21 | public function getTargets(): string 22 | { 23 | return self::PROPERTY_CONSTRAINT; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/src/Validator/BookUrlValidator.php: -------------------------------------------------------------------------------- 1 | bookRepository->find($value)) { 33 | $this->context->buildViolation($constraint->message)->addViolation(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/src/Validator/UniqueUserBook.php: -------------------------------------------------------------------------------- 1 | message = $message ?? $this->message; 19 | } 20 | 21 | public function getTargets(): string 22 | { 23 | return self::CLASS_CONSTRAINT; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/src/Validator/UniqueUserBookValidator.php: -------------------------------------------------------------------------------- 1 | security->getUser(); 39 | if (!$value || !$user || !($book = $this->propertyAccessor->getValue($value, 'book'))) { 40 | return; 41 | } 42 | 43 | if (!$book instanceof Book) { 44 | throw new UnexpectedValueException($value, Book::class); 45 | } 46 | 47 | $className = ClassUtils::getRealClass($value::class); 48 | $manager = $this->registry->getManagerForClass($className); 49 | if (!$manager) { 50 | throw new ValidatorException(\sprintf('"%s" is not a valid entity.', $className)); 51 | } 52 | 53 | if ($manager->getRepository($className)->findOneBy(['user' => $user, 'book' => $book])) { 54 | $this->context->buildViolation($constraint->message)->addViolation(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /api/templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | 7 | {% block stylesheets %} 8 | {% endblock %} 9 | 10 | {% block javascripts %} 11 | {% endblock %} 12 | 13 | 14 | {% block body %}{% endblock %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /api/tests/Api/Admin/Trait/UsersDataProviderTrait.php: -------------------------------------------------------------------------------- 1 | jwk = JWK::createFromJson(json: $jwk); 30 | } 31 | 32 | public function generateToken(array $claims): string 33 | { 34 | // Defaults 35 | $time = time(); 36 | $sub = Uuid::v7()->__toString(); 37 | $claims += [ 38 | 'sub' => $sub, 39 | 'iat' => $time, 40 | 'nbf' => $time, 41 | 'exp' => $time + 3600, 42 | 'iss' => $this->issuer, 43 | 'aud' => $this->audience, 44 | 'given_name' => 'John', 45 | 'family_name' => 'DOE', 46 | ]; 47 | if (empty($claims['sub'])) { 48 | $claims['sub'] = $sub; 49 | } 50 | if (empty($claims['iat'])) { 51 | $claims['iat'] = $time; 52 | } 53 | if (empty($claims['nbf'])) { 54 | $claims['nbf'] = $time; 55 | } 56 | if (empty($claims['exp'])) { 57 | $claims['exp'] = $time + 3600; 58 | } 59 | if (empty($claims['realm_access']) || empty($claims['realm_access']['roles'])) { 60 | $claims['realm_access']['roles'] = ['chuck.norris@example.com' === ($claims['email'] ?? null) ? 'admin' : 'user']; 61 | } 62 | 63 | return $this->jwsSerializerManager->serialize( 64 | name: 'jws_compact', 65 | jws: $this->jwsBuilder 66 | ->withPayload(json_encode($claims)) 67 | ->addSignature($this->jwk, ['alg' => $this->jwk->get('alg')]) 68 | ->build(), 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/tests/Api/Security/Voter/Mock/NotImplementedMock.php: -------------------------------------------------------------------------------- 1 | handleRequest(...), $baseUri); 19 | } 20 | 21 | public function handleRequest(string $method, string $url): void 22 | { 23 | throw new \UnexpectedValueException("Mock not implemented: {$method}/{$url}"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/tests/Api/Trait/SerializerTrait.php: -------------------------------------------------------------------------------- 1 | has(SerializerInterface::class)) { 16 | return $container->get(SerializerInterface::class)->serialize($data, $format, $context); 17 | } 18 | 19 | static::fail('A client must have Serializer enabled to make serialization. Did you forget to require symfony/serializer?'); 20 | } 21 | 22 | public static function getOperationNormalizationContext(string $resourceClass, ?string $operationName = null): array 23 | { 24 | if ($resourceMetadataFactoryCollection = static::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory')) { 25 | $operation = $resourceMetadataFactoryCollection->create($resourceClass)->getOperation($operationName); 26 | } else { 27 | $operation = $operationName ? (new Get())->withName($operationName) : new Get(); 28 | } 29 | 30 | return ($operation->getNormalizationContext() ?? []) + ['item_uri_template' => $operation->getUriTemplate()]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/tests/State/Processor/BookPersistProcessorTest.php: -------------------------------------------------------------------------------- 1 | persistProcessorMock = $this->createMock(ProcessorInterface::class); 27 | $this->bookRepositoryMock = $this->createMock(BookRepositoryInterface::class); 28 | $this->objectMock = $this->createMock(Book::class); 29 | $this->objectMock->book = 'https://openlibrary.org/books/OL2055137M.json'; 30 | $this->operationMock = $this->createMock(Operation::class); 31 | 32 | $this->processor = new BookPersistProcessor( 33 | $this->persistProcessorMock, 34 | $this->bookRepositoryMock 35 | ); 36 | } 37 | 38 | #[Test] 39 | public function itUpdatesBookDataBeforeSaveAndSendMercureUpdates(): void 40 | { 41 | $expectedData = $this->objectMock; 42 | $expectedData->title = 'Foundation'; 43 | $expectedData->author = 'Dan Simmons'; 44 | 45 | $this->bookRepositoryMock 46 | ->expects($this->once()) 47 | ->method('find') 48 | ->willReturn($expectedData) 49 | ; 50 | $this->persistProcessorMock 51 | ->expects($this->once()) 52 | ->method('process') 53 | ->with($expectedData, $this->operationMock, [], []) 54 | ->willReturn($expectedData) 55 | ; 56 | 57 | $this->assertEquals($expectedData, $this->processor->process($this->objectMock, $this->operationMock)); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/tests/State/Processor/BookRemoveProcessorTest.php: -------------------------------------------------------------------------------- 1 | removeProcessorMock = $this->createMock(ProcessorInterface::class); 29 | $this->resourceMetadataCollection = new ResourceMetadataCollection(Book::class, [ 30 | new ApiResource(operations: [new Get('/admin/books/{id}{._format}')]), 31 | new ApiResource(operations: [new Get('/books/{id}{._format}')]), 32 | ]); 33 | $this->objectMock = $this->createMock(Book::class); 34 | $this->operationMock = $this->createMock(Operation::class); 35 | 36 | $this->processor = new BookRemoveProcessor($this->removeProcessorMock); 37 | } 38 | 39 | #[Test] 40 | public function itRemovesBookAndSendMercureUpdates(): void 41 | { 42 | $this->removeProcessorMock 43 | ->expects($this->once()) 44 | ->method('process') 45 | ->with($this->objectMock, $this->operationMock, [], []) 46 | ; 47 | 48 | $this->processor->process($this->objectMock, $this->operationMock); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/tests/State/Processor/BookmarkPersistProcessorTest.php: -------------------------------------------------------------------------------- 1 | persistProcessorMock = $this->createMock(ProcessorInterface::class); 32 | $this->securityMock = $this->createMock(Security::class); 33 | $this->userMock = $this->createMock(User::class); 34 | $this->objectMock = $this->createMock(Bookmark::class); 35 | $this->operationMock = $this->createMock(Operation::class); 36 | $this->clockMock = new MockClock(); 37 | 38 | $this->processor = new BookmarkPersistProcessor($this->persistProcessorMock, $this->securityMock, $this->clockMock); 39 | } 40 | 41 | #[Test] 42 | public function itUpdatesBookmarkDataBeforeSave(): void 43 | { 44 | $expectedData = $this->objectMock; 45 | $expectedData->user = $this->userMock; 46 | $expectedData->bookmarkedAt = $this->clockMock->now(); 47 | 48 | $this->securityMock 49 | ->expects($this->once()) 50 | ->method('getUser') 51 | ->willReturn($this->userMock) 52 | ; 53 | $this->persistProcessorMock 54 | ->expects($this->once()) 55 | ->method('process') 56 | ->with($expectedData, $this->operationMock, [], []) 57 | ->willReturn($expectedData) 58 | ; 59 | 60 | $this->assertEquals($expectedData, $this->processor->process($this->objectMock, $this->operationMock)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__) . '/.env'); 17 | } 18 | 19 | if ($_SERVER['APP_DEBUG']) { 20 | umask(0000); 21 | } 22 | 23 | // https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#non-kernel-tests 24 | UnitTestConfig::configure(instantiator: (Instantiator::withoutConstructor()) 25 | ->allowExtra() 26 | ->alwaysForce(), faker: FakerFactory::create('en_GB')); 27 | 28 | // https://github.com/symfony/symfony/issues/53812#issuecomment-1962740145 29 | set_exception_handler([new ErrorHandler(), 'handleException']); 30 | -------------------------------------------------------------------------------- /compose.e2e.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | keycloak: 3 | environment: 4 | KEYCLOAK_ENABLE_HTTPS: "true" 5 | KEYCLOAK_HTTPS_USE_PEM: "true" 6 | KEYCLOAK_HTTPS_CERTIFICATE_FILE: /opt/bitnami/keycloak/certs/tls.crt 7 | KEYCLOAK_HTTPS_CERTIFICATE_KEY_FILE: /opt/bitnami/keycloak/certs/tls.key 8 | KEYCLOAK_EXTRA_ARGS: "--import-realm" 9 | volumes: 10 | - ./helm/api-platform/keycloak/certs/tls.crt:/opt/bitnami/keycloak/certs/tls.crt:ro 11 | - ./helm/api-platform/keycloak/certs/tls.pem:/opt/bitnami/keycloak/certs/tls.key:ro 12 | - ./helm/api-platform/keycloak/config:/opt/bitnami/keycloak/data/import 13 | -------------------------------------------------------------------------------- /compose.override.yaml: -------------------------------------------------------------------------------- 1 | # Development environment override 2 | services: 3 | php: 4 | build: 5 | context: ./api 6 | target: frankenphp_dev 7 | volumes: 8 | - ./api:/app 9 | - /app/var 10 | - ./api/frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro 11 | - ./api/frankenphp/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro 12 | # If you develop on Mac or Windows you can remove the vendor/ directory 13 | # from the bind-mount for better performance by enabling the next line: 14 | #- /app/vendor 15 | environment: 16 | MERCURE_EXTRA_DIRECTIVES: demo 17 | # See https://xdebug.org/docs/all_settings#mode 18 | XDEBUG_MODE: "${XDEBUG_MODE:-off}" 19 | extra_hosts: 20 | # Ensure that host.docker.internal is correctly defined on Linux 21 | - host.docker.internal:host-gateway 22 | tty: true 23 | 24 | pwa: 25 | build: 26 | context: ./pwa 27 | target: dev 28 | volumes: 29 | - ./pwa:/srv/app 30 | environment: 31 | API_PLATFORM_CREATE_CLIENT_ENTRYPOINT: http://php 32 | API_PLATFORM_CREATE_CLIENT_OUTPUT: . 33 | # On Linux, you may want to comment the following line for improved performance 34 | WATCHPACK_POLLING: "true" 35 | # Development usage only 36 | NODE_TLS_REJECT_UNAUTHORIZED: "0" 37 | healthcheck: 38 | test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000 || exit 1" ] 39 | start_period: 15s 40 | interval: 5s 41 | timeout: 3s 42 | retries: 5 43 | 44 | ###> doctrine/doctrine-bundle ### 45 | database: 46 | ports: 47 | - target: 5432 48 | published: 5432 49 | protocol: tcp 50 | ###< doctrine/doctrine-bundle ### 51 | 52 | ###> symfony/mercure-bundle ### 53 | ###< symfony/mercure-bundle ### 54 | 55 | keycloak: 56 | build: 57 | context: ./helm/api-platform/keycloak/ 58 | target: keycloak 59 | environment: 60 | KEYCLOAK_EXTRA_ARGS: "--import-realm" 61 | volumes: 62 | - ./helm/api-platform/keycloak/themes/api-platform-demo:/opt/bitnami/keycloak/themes/api-platform-demo 63 | - ./helm/api-platform/keycloak/config:/opt/bitnami/keycloak/data/import 64 | -------------------------------------------------------------------------------- /compose.prod.yaml: -------------------------------------------------------------------------------- 1 | # Production environment override 2 | services: 3 | php: 4 | image: ${PHP_DOCKER_IMAGE} 5 | build: 6 | context: ./api 7 | target: frankenphp_prod 8 | environment: 9 | APP_ENV: prod 10 | APP_SECRET: ${APP_SECRET} 11 | MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} 12 | MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} 13 | 14 | pwa: 15 | image: ${PWA_DOCKER_IMAGE} 16 | build: 17 | context: ./pwa 18 | target: prod 19 | args: 20 | AUTH_SECRET: ${AUTH_SECRET} 21 | # https://nextjs.org/docs/app/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser 22 | NEXT_PUBLIC_OIDC_SERVER_URL: ${NEXT_PUBLIC_OIDC_SERVER_URL} 23 | environment: 24 | AUTH_SECRET: ${AUTH_SECRET} 25 | healthcheck: 26 | test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000 || exit 1" ] 27 | start_period: 5s 28 | interval: 5s 29 | timeout: 3s 30 | retries: 5 31 | 32 | database: 33 | environment: 34 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 35 | 36 | keycloak-database: 37 | environment: 38 | POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD} 39 | 40 | keycloak: 41 | image: ${KEYCLOAK_DOCKER_IMAGE} 42 | build: 43 | context: ./helm/api-platform/keycloak/ 44 | target: keycloak 45 | environment: 46 | KEYCLOAK_PRODUCTION: "true" 47 | KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD} 48 | -------------------------------------------------------------------------------- /docs/.markdown-lint.yaml: -------------------------------------------------------------------------------- 1 | MD013: false 2 | -------------------------------------------------------------------------------- /docs/adr/0000-book-fields.md: -------------------------------------------------------------------------------- 1 | # Book Fields 2 | 3 | * Status: accepted 4 | * Deciders: @gregoirehebert, @vincentchalamon 5 | 6 | ## Context and Problem Statement 7 | 8 | Considering the Book resource with a `book` property, exposing an IRI of a book from the BNF API. With this 9 | architecture, the client could request this IRI to retrieve the book data. 10 | 11 | But how can the client filters the books by title and author from our API if we don't handle those properties? 12 | 13 | ## Considered Options 14 | 15 | A first option would be to let the client request on the BNF API, retrieve a collection of books IRI, then use it to 16 | filter the books collection on our API (using `book` query filter). This approach cannot work properly because the BNF 17 | API will return IRIs which won't be registered in our API. 18 | 19 | Another option would be to enable custom filters on our API (`title` and `author`). Then, the API will call the BNF 20 | API to retrieve a collection of books IRI, and use it in a Doctrine query to filter the API Book objects by the `book` 21 | property. It exposes the API to a performance issue if the BNF API returns a huge amount of IRIs. Restricting this 22 | collection (e.g.: limiting the BNF request to 100 results) may ignore some books arriving later. 23 | 24 | To fix this last option issues, another option is to list all API Book IRIs, then filter the BNF API by title, author 25 | and this collection to retrieve only IRIs that match those filters. But the performance issue still remains if our API 26 | manages a huge collection of books. 27 | 28 | Finally, the last considered option would be to duplicate the title and author properties on our API for filtering 29 | usage. 30 | 31 | ## Decision Outcome 32 | 33 | The last considered option has been selected as the best compromise in such situation. The API will call the BNF API on 34 | Book creation, retrieve the `title` and `author` properties and save them for local filtering usage. 35 | -------------------------------------------------------------------------------- /docs/adr/0001-save-book-data-from-bnf-api.md: -------------------------------------------------------------------------------- 1 | # Save Book Data from BNF API 2 | 3 | * Status: accepted 4 | * Deciders: @gregoirehebert, @vincentchalamon 5 | 6 | ## Context and Problem Statement 7 | 8 | Some Book data come from the BNF API (cf. [Book Fields](0000-book-fields.md)). How to retrieve and aggregate them 9 | before saving a Book object in the database? 10 | 11 | ## Considered Options 12 | 13 | A first option would be to use a custom entity listener with Doctrine. This approach would let us complete the Book 14 | entity right before save by calling the BNF API and retrieving the properties. But using those lifecycle callbacks are 15 | a bad practice and "_are supposed to be the ORM-specific serialize and unserialize_" 16 | (cf. ["Doctrine 2 ORM Best Practices" by Ocramius](https://ocramius.github.io/doctrine-best-practices/)). 17 | 18 | Another option would be to use a custom [State Processor](https://api-platform.com/docs/core/state-processors/) to 19 | retrieve the data from the BNF API, then update and save the Book object. 20 | 21 | ## Decision Outcome 22 | 23 | The last solution is preferred as it's the recommended way by API Platform to handle a custom save on a resource. 24 | 25 | ## Links 26 | 27 | * [Book Fields ADR](0000-book-fields.md) 28 | * ["Doctrine 2 ORM Best Practices" by Ocramius](https://ocramius.github.io/doctrine-best-practices/) 29 | * [API Platform State Processor](https://api-platform.com/docs/core/state-processors/) 30 | -------------------------------------------------------------------------------- /docs/adr/0002-book-reviews-property-as-collection-iri.md: -------------------------------------------------------------------------------- 1 | # Book.reviews Property as Collection IRI 2 | 3 | * Status: accepted 4 | * Deciders: @gregoirehebert, @vincentchalamon 5 | 6 | ## Context and Problem Statement 7 | 8 | A Book may have a lot of reviews, like thousands. Exposing the reviews on a Book may cause an over-fetching issue. 9 | 10 | The client may have to show the reviews, or may not. For instance, we want the front client to show the reviews on a 11 | Book page, but on the admin client it's not necessary. 12 | 13 | How can we expose a Book reviews without provoking any under/over-fetching, and without requesting the reviews on the 14 | database when it's not necessary? 15 | 16 | ## Considered Options 17 | 18 | Thanks to [Vulcain](https://vulcain.rocks/), it is possible to preload some data and push them to the client. But how 19 | the `Book.reviews` data should be exposed? 20 | 21 | The first considered option would be to only expose the IRIs of each review from a Book. But it doesn't solve the 22 | over-fetching issue if the Book has a lot of reviews. Also, this list wouldn't be paginated nor filtered. These would 23 | be huge limitations over the reviews collection. 24 | 25 | Another considered option is to expose the IRI of the Book reviews (e.g.: `/books/{id}/reviews`), and let 26 | [Vulcain](https://vulcain.rocks/) request it when necessary. This IRI would expose a paginated and filtered list of 27 | reviews related to this Book. It would also be possible to manage the authorization differently than Review main 28 | endpoint, for admin usage for instance. 29 | 30 | ## Decision Outcome 31 | 32 | The last option would be the best solution as it respects the 33 | [Hydra Spec](https://www.hydra-cg.com/spec/latest/core/#example-5-using-json-ld-s-type-coercion-feature-to-create-idiomatic-representations) 34 | and prevent any over-fetching. 35 | 36 | The Book JSON-LD response would return an IRI for `reviews` property, which can be parsed with 37 | [Vulcain](https://vulcain.rocks/) to preload them, and keep any pagination and filtering features. 38 | 39 | ## Links 40 | 41 | * [Vulcain](https://vulcain.rocks/) 42 | * [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) 43 | -------------------------------------------------------------------------------- /docs/adr/0003-live-refresh-on-book-updates.md: -------------------------------------------------------------------------------- 1 | # Live Refresh on Book Updates 2 | 3 | * Status: accepted 4 | * Deciders: @gregoirehebert, @vincentchalamon 5 | 6 | ## Context and Problem Statement 7 | 8 | When an admin creates, updates or removes a book, the users must instantly see this modification on the client. 9 | 10 | ## Considered Options 11 | 12 | PostgreSQL implements a [Notify](https://www.postgresql.org/docs/current/sql-notify.html) command which sends a 13 | notification event together with an optional "payload" string to each client application that has previously 14 | executed a `LISTEN **_channel_**` for the specified channel name in the current database. This option implies a custom 15 | PHP script on the API to handle the connection between the client and the database, which requires tests, performances 16 | checks, security, etc. It also implies a PostgreSQL procedure which locks the API to this database system. 17 | 18 | [WebSockets API](https://developer.mozilla.org/fr/docs/Web/API/WebSockets_API) is an advanced technology to open a 19 | two-way interactive communication session between the user's browser and the server. [Caddy](https://caddyserver.com/) 20 | is able to handle it, as many other servers and solutions. This would be a valid working solution. 21 | 22 | [Meteor.js](https://www.meteor.com/) is an open source platform for seamlessly building and deploying Web, Mobile, and 23 | Desktop applications in Javascript or TypeScript. Installed as an API Gateway, it would be a valid working solution too. 24 | 25 | [Mercure](https://mercure.rocks/) is an open solution for real-time communications designed to be fast, reliable and 26 | battery-efficient. As previous solutions, it would be a valid working one. 27 | 28 | ## Decision Outcome 29 | 30 | Among all those good solutions found, [Mercure](https://mercure.rocks/) would be the most appropriate one thanks to its 31 | integration in API Platform and Caddy. No extra server would be necessary, and it's easily usable with API Platform. 32 | 33 | ## Links 34 | 35 | * [PostgreSQL Notify](https://www.postgresql.org/docs/current/sql-notify.html) 36 | * [WebSockets API](https://developer.mozilla.org/fr/docs/Web/API/WebSockets_API) 37 | * [Caddy](https://caddyserver.com/) 38 | * [Meteor.js](https://www.meteor.com/) 39 | * [Mercure](https://mercure.rocks/) 40 | -------------------------------------------------------------------------------- /docs/adr/0004-get-user-data.md: -------------------------------------------------------------------------------- 1 | # Get User Data 2 | 3 | * Status: accepted 4 | * Deciders: @gregoirehebert, @vincentchalamon 5 | 6 | ## Context and Problem Statement 7 | 8 | When a user downloads a book, a Download object is created. An admin can list all those objects to check all the books 9 | that have been downloaded. For each of them, an admin must see the data of the user (`firstName` and `lastName`). 10 | 11 | Users come from an OIDC server. 12 | 13 | ## Considered Options 14 | 15 | A Download object should save the IRI of the user from the OIDC server 16 | (e.g.: `https://demo.api-platform.com/oidc/users/{id}`). Then, the admin client could authenticate on the OIDC API, and 17 | request this IRI to retrieve the user data. This project currently uses [Keycloak](https://keycloak.org/) OIDC server, 18 | which only enables the API for administrators of the OIDC server for security reasons. The admin client would not be 19 | able to request it, as the admin client user is not the same as an administrator of the OIDC server. 20 | 21 | Another option would be to create exactly the same users on [Keycloak](https://keycloak.org/) and the API. But what if a 22 | new user is added on [Keycloak](https://keycloak.org/)? It won't be automatically synchronized on the API, some data 23 | might be different. 24 | 25 | Last solution would be on the API side. The authentication process is already done by [Keycloak](https://keycloak.org/). 26 | A check is done on the API side thanks to Symfony. If the user is valid and fully authenticated according to this 27 | authenticator and [Keycloak](https://keycloak.org/), we could try to find the user in the database or create it, and 28 | update it if necessary. 29 | 30 | ## Decision Outcome 31 | 32 | The last solution would be the best compromise. Thanks to it, the users on the API will always be synchronized with 33 | [Keycloak](https://keycloak.org/), and we're able to expose an API over the users restricted to admins 34 | (e.g.: `https://demo.api-platform.com/users/{id}`). 35 | 36 | ## Links 37 | 38 | * [Keycloak](https://keycloak.org/) 39 | * [Symfony AccessToken Authenticator](https://symfony.com/doc/current/security/access_token.html) 40 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /blob-report/ 5 | /playwright/.cache/ 6 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": {}, 6 | "keywords": [], 7 | "author": "", 8 | "license": "ISC", 9 | "description": "", 10 | "devDependencies": { 11 | "@playwright/test": "^1.50.0", 12 | "@types/node": "^22.9.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /e2e/playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { defineConfig, devices } = require('@playwright/test'); 3 | 4 | module.exports = defineConfig({ 5 | timeout: 45000, 6 | expect: { 7 | timeout: 10000, 8 | }, 9 | testDir: './tests', 10 | fullyParallel: true, 11 | forbidOnly: !!process.env.CI, 12 | retries: 0, 13 | workers: 1, 14 | reporter: process.env.CI ? 'github' : 'line', 15 | use: { 16 | ignoreHTTPSErrors: true, 17 | trace: 'on-first-retry', 18 | screenshot: 'only-on-failure', 19 | baseURL: 'https://localhost', 20 | }, 21 | 22 | projects: [ 23 | { 24 | name: 'chromium', 25 | use: { ...devices['Desktop Chrome'], }, 26 | retries: 1, 27 | fullyParallel: true, 28 | }, 29 | // { 30 | // name: 'firefox', 31 | // use: { ...devices['Desktop Firefox'] }, 32 | // }, 33 | // { 34 | // name: 'webkit', 35 | // use: { ...devices['Desktop Safari'] }, 36 | // }, 37 | ], 38 | }); 39 | -------------------------------------------------------------------------------- /e2e/tests/GraphQL.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test.describe("GraphQL", () => { 4 | test("Check GraphQL playground @read", async ({ page }) => { 5 | await page.goto("/graphql"); 6 | await expect(page.getByTestId("graphiql-container")).toContainText("Welcome to GraphiQL"); 7 | }); 8 | }) 9 | -------------------------------------------------------------------------------- /e2e/tests/User.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "./test"; 2 | 3 | test.describe("User authentication", () => { 4 | test.beforeEach(async ({ bookPage }) => { 5 | await bookPage.gotoList(); 6 | }); 7 | 8 | test("I can log in Books Store @login", async ({ userPage, page }) => { 9 | await expect(page.getByText("Log in")).toBeVisible(); 10 | await expect(page.getByText("Sign out")).toHaveCount(0); 11 | 12 | await page.getByText("Log in").click(); 13 | await page.getByText("Log in").waitFor({ state: "hidden" }); 14 | // @ts-ignore assert declared on test.ts 15 | await expect(page).toBeOnLoginPage(); 16 | await expect(page.locator("#kc-header-wrapper")).toContainText("API Platform - Demo"); 17 | await expect(page.locator("#kc-form-login")).toContainText("Login as user: john.doe@example.com"); 18 | await expect(page.locator("#kc-form-login")).toContainText("Login as admin: chuck.norris@example.com"); 19 | await userPage.login(); 20 | 21 | await expect(page.getByText("Log in")).toHaveCount(0); 22 | await expect(page.getByText("Sign out")).toBeVisible(); 23 | }); 24 | 25 | test("I can sign out of Books Store @login", async ({ userPage, page }) => { 26 | await page.getByText("Log in").click(); 27 | await userPage.login(); 28 | await page.getByText("Sign out").click(); 29 | 30 | await expect(page.getByText("Log in")).toBeVisible(); 31 | await expect(page.getByText("Sign out")).toHaveCount(0); 32 | 33 | // I should be logged out from Keycloak also 34 | await page.getByText("Log in").click(); 35 | // @ts-ignore assert declared on test.ts 36 | await expect(page).toBeOnLoginPage(); 37 | await expect(page.locator("#kc-header-wrapper")).toContainText("API Platform - Demo"); 38 | await expect(page.locator("#kc-form-login")).toContainText("Login as user: john.doe@example.com"); 39 | await expect(page.locator("#kc-form-login")).toContainText("Login as admin: chuck.norris@example.com"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /e2e/tests/admin/BookEdit.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "./test"; 2 | 3 | test.describe("Edit a book @admin", () => { 4 | test.beforeEach(async ({ bookPage, page }) => { 5 | await bookPage.gotoList(); 6 | await page.locator(".datagrid-body tr").last().getByRole("link", { name: "Edit", exact: true }).click(); 7 | }); 8 | 9 | test("I can edit a book @write", async ({ page }) => { 10 | // fill in Book Reference 11 | await page.getByLabel("Book Reference").fill("Asimov"); 12 | await page.getByRole("listbox").getByText("The Genetic Effects of Radiation - Asimov, Isaac", { exact: true }).waitFor({ state: "visible" }); 13 | await page.getByRole("listbox").getByText("The Genetic Effects of Radiation - Asimov, Isaac", { exact: true }).click(); 14 | await expect(page.getByRole("listbox")).not.toBeAttached(); 15 | await expect(page.getByLabel("Book Reference")).toHaveValue("The Genetic Effects of Radiation - Asimov, Isaac"); 16 | 17 | // fill in condition 18 | await page.getByLabel("Condition").click(); 19 | await page.getByRole("listbox").getByText("Damaged").waitFor({ state: "visible" }); 20 | await page.getByRole("listbox").getByText("Damaged").click(); 21 | await expect(page.getByRole("listbox")).not.toBeAttached(); 22 | await expect(page.locator(".MuiSelect-nativeInput[name=condition]")).toHaveValue("https://schema.org/DamagedCondition"); 23 | 24 | // submit form 25 | await page.getByRole("button", { name: "Save", exact: true }).click(); 26 | await expect(page.getByLabel("Book Reference")).not.toBeAttached(); 27 | await expect(page.getByText("Element updated")).toBeVisible(); 28 | }); 29 | 30 | test("I can delete a book @write", async ({ page }) => { 31 | await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); 32 | await page.getByRole("button", { name: "Delete" }).click(); 33 | await expect(page.getByRole("button", { name: "Confirm" })).toBeVisible(); 34 | await page.getByRole("button", { name: "Confirm" }).click(); 35 | await page.getByRole("button", { name: "Confirm" }).waitFor({ state: "detached" }); 36 | await expect(page.getByLabel("Book Reference")).not.toBeAttached(); 37 | await expect(page.getByText("Element deleted")).toBeVisible(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /e2e/tests/admin/ReviewEdit.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "./test"; 2 | 3 | test.describe("Edit a review @admin", () => { 4 | test.beforeEach(async ({ reviewPage, page }) => { 5 | await reviewPage.gotoList(); 6 | await page.locator(".datagrid-body tr").last().getByRole("link", { name: "Edit", exact: true }).click(); 7 | }); 8 | 9 | test("I can edit a review @write", async ({ page }) => { 10 | // await page.waitForTimeout(300); 11 | 12 | // fill in book 13 | await expect(page.getByLabel("Book")).not.toHaveValue(""); 14 | await page.getByLabel("Book").fill("Hyperion - Dan Simmons"); 15 | await page.getByRole("listbox").getByText("Hyperion - Dan Simmons", { exact: true }).waitFor({ state: "visible" }); 16 | await page.getByRole("listbox").getByText("Hyperion - Dan Simmons", { exact: true }).click(); 17 | await expect(page.getByRole("listbox")).not.toBeAttached(); 18 | await expect(page.getByLabel("Book")).toHaveValue("Hyperion - Dan Simmons"); 19 | 20 | // fill in body 21 | await page.getByLabel("Body").fill("Lorem ipsum dolor sit amet."); 22 | 23 | // fill in rating 24 | await page.locator(".MuiRating-root label").nth(4).click(); 25 | 26 | // submit form 27 | await page.getByRole("button", { name: "Save", exact: true }).click(); 28 | await expect(page.getByLabel("Book")).not.toBeAttached(); 29 | await expect(page.getByText("Element updated")).toBeVisible(); 30 | }); 31 | 32 | test("I can delete a review @write", async ({ page }) => { 33 | await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); 34 | await page.getByRole("button", { name: "Delete" }).click(); 35 | await expect(page.getByRole("button", { name: "Confirm" })).toBeVisible(); 36 | await page.getByRole("button", { name: "Confirm" }).click(); 37 | await expect(page.getByLabel("Book")).not.toBeAttached(); 38 | await expect(page.getByText("Element deleted")).toBeVisible(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /e2e/tests/admin/User.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "./test"; 2 | 3 | test.describe("User authentication", () => { 4 | test.beforeEach(async ({ bookPage }) => { 5 | await bookPage.gotoList(); 6 | }); 7 | 8 | test("I can sign out of Admin @login", async ({ userPage, page }) => { 9 | await page.getByLabel("Profile").click(); 10 | await page.getByRole("menu").getByText("Logout").waitFor({ state: "visible" }); 11 | await page.getByRole("menu").getByText("Logout").click(); 12 | 13 | await expect(page).toHaveURL(/\/$/); 14 | 15 | // I should be logged out from Keycloak also 16 | await page.goto("/admin"); 17 | await page.waitForURL(/\/oidc\/realms\/demo\/protocol\/openid-connect\/auth/); 18 | // @ts-ignore assert declared on test.ts 19 | await expect(page).toBeOnLoginPage(); 20 | await expect(page.locator("#kc-header-wrapper")).toContainText("API Platform - Demo"); 21 | await expect(page.locator("#kc-form-login")).toContainText("Login as user: john.doe@example.com"); 22 | await expect(page.locator("#kc-form-login")).toContainText("Login as admin: chuck.norris@example.com"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/tests/admin/pages/AbstractPage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | export abstract class AbstractPage { 4 | constructor(protected readonly page: Page) { 5 | } 6 | 7 | public async login() { 8 | await this.page.getByLabel("Email").fill("chuck.norris@example.com"); 9 | await this.page.getByLabel("Password").fill("Pa55w0rd"); 10 | await this.page.getByRole("button", { name: "Sign In" }).click(); 11 | if (await this.page.getByRole("button", { name: "Sign in with Keycloak" }).count()) { 12 | await this.page.getByRole("button", { name: "Sign in with Keycloak" }).click(); 13 | } 14 | 15 | return this.page; 16 | } 17 | 18 | protected async registerMock() { 19 | await this.page.route(/^https:\/\/openlibrary\.org\/books\/(.+)\.json$/, (route) => route.fulfill({ 20 | path: "tests/mocks/openlibrary.org/books/OL2055137M.json" 21 | })); 22 | await this.page.route(/^https:\/\/openlibrary\.org\/works\/(.+)\.json$/, (route) => route.fulfill({ 23 | path: "tests/mocks/openlibrary.org/works/OL1963268W.json" 24 | })); 25 | await this.page.route("https://openlibrary.org/search.json?q=Foundation%20Isaac%20Asimov&limit=10", (route) => route.fulfill({ 26 | path: "tests/mocks/openlibrary.org/search/Foundation-Isaac-Asimov.json" 27 | })); 28 | await this.page.route("https://openlibrary.org/search.json?q=Eon%20Greg%20Bear&limit=10", (route) => route.fulfill({ 29 | path: "tests/mocks/openlibrary.org/search/Eon-Greg-Bear.json" 30 | })); 31 | await this.page.route("https://openlibrary.org/search.json?q=Hyperion%20Dan%20Simmons&limit=10", (route) => route.fulfill({ 32 | path: "tests/mocks/openlibrary.org/search/Hyperion-Dan-Simmons.json" 33 | })); 34 | await this.page.route(/^https:\/\/covers\.openlibrary.org\/b\/id\/(.+)\.jpg$/, (route) => route.fulfill({ 35 | path: "tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg", 36 | })); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /e2e/tests/admin/pages/BookPage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPage } from "./AbstractPage"; 2 | 3 | interface FiltersProps { 4 | author?: string | undefined; 5 | title?: string | undefined; 6 | condition?: string | undefined; 7 | } 8 | 9 | export class BookPage extends AbstractPage { 10 | public async gotoList() { 11 | await this.registerMock(); 12 | 13 | await this.page.goto("/admin"); 14 | await this.login(); 15 | await this.page.waitForURL(/\/admin#\/admin/); 16 | await this.page.locator(".RaSidebar-fixed").getByText("Books").click(); 17 | 18 | return this.page; 19 | } 20 | 21 | public async getDefaultBook() { 22 | return this.page.locator(".datagrid-body tr").filter({ hasText: "Hyperion" }); 23 | } 24 | 25 | public async filter(filters: FiltersProps) { 26 | if (filters.author) { 27 | await this.page.getByLabel("Add filter").click(); 28 | await this.page.getByRole("menu").getByText("Author").waitFor({ state: "visible" }); 29 | await this.page.getByRole("menu").getByText("Author").click(); 30 | await this.page.getByRole("textbox", { name: "Author" }).fill(filters.author); 31 | await this.page.waitForResponse(/\/books/); 32 | } 33 | 34 | if (filters.title) { 35 | await this.page.getByLabel("Add filter").click(); 36 | await this.page.getByRole("menu").getByText("Title").waitFor({ state: "visible" }); 37 | await this.page.getByRole("menu").getByText("Title").click(); 38 | await this.page.getByRole("textbox", { name: "Title" }).fill(filters.title); 39 | await this.page.waitForResponse(/\/books/); 40 | } 41 | 42 | if (filters.condition) { 43 | await this.page.getByLabel("Add filter").click(); 44 | await this.page.getByRole("menu").getByText("Condition").waitFor({ state: "visible" }); 45 | await this.page.getByRole("menu").getByText("Condition").click(); 46 | await this.page.getByLabel("Condition").click(); 47 | await this.page.getByRole("listbox").getByText(filters.condition).waitFor({ state: "visible" }); 48 | await this.page.getByRole("listbox").getByText(filters.condition).click(); 49 | await this.page.waitForResponse(/\/books/); 50 | } 51 | 52 | return this.page; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /e2e/tests/admin/pages/ReviewPage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPage } from "./AbstractPage"; 2 | 3 | interface FiltersProps { 4 | user?: string | undefined; 5 | book?: string | undefined; 6 | rating?: number | undefined; 7 | } 8 | 9 | export class ReviewPage extends AbstractPage { 10 | public async gotoList() { 11 | await this.page.goto("/admin"); 12 | await this.login(); 13 | await this.page.waitForURL(/\/admin#\/admin/); 14 | await this.page.locator(".RaSidebar-fixed").getByText("Reviews").click(); 15 | 16 | return this.page; 17 | } 18 | 19 | public async getDefaultReview() { 20 | return this.page.locator(".datagrid-body tr").filter({ hasText: "John Doe" }); 21 | } 22 | 23 | public async filter(filters: FiltersProps) { 24 | if (filters.user) { 25 | await this.page.getByLabel("Add filter").click(); 26 | await this.page.getByRole("menu").getByText("User").waitFor({ state: "visible" }); 27 | await this.page.getByRole("menu").getByText("User").click(); 28 | await this.page.getByRole("combobox", { name: "User" }).fill(filters.user); 29 | await this.page.getByRole("listbox").getByText(filters.user, { exact: true }).click(); 30 | await this.page.waitForResponse(/\/reviews/); 31 | } 32 | 33 | if (filters.book) { 34 | await this.page.getByLabel("Add filter").click(); 35 | await this.page.getByRole("menu").getByText("Book").waitFor({ state: "visible" }); 36 | await this.page.getByRole("menu").getByText("Book").click(); 37 | await this.page.getByLabel("Book").fill(filters.book); 38 | await this.page.getByRole("listbox").getByText(filters.book, { exact: true }).click(); 39 | await this.page.waitForResponse(/\/reviews/); 40 | } 41 | 42 | if (filters.rating) { 43 | await this.page.getByLabel("Add filter").click(); 44 | await this.page.getByRole("menu").getByText("Rating").waitFor({ state: "visible" }); 45 | await this.page.getByRole("menu").getByText("Rating").click(); 46 | await this.page.locator(".MuiRating-root label").nth(filters.rating-1).click(); 47 | await this.page.waitForResponse(/\/reviews/); 48 | } 49 | 50 | return this.page; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /e2e/tests/admin/pages/UserPage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPage } from "./AbstractPage"; 2 | 3 | export class UserPage extends AbstractPage { 4 | } 5 | -------------------------------------------------------------------------------- /e2e/tests/admin/test.ts: -------------------------------------------------------------------------------- 1 | import { Page, test as playwrightTest } from "@playwright/test"; 2 | 3 | import { expect } from "../test"; 4 | import { BookPage } from "./pages/BookPage"; 5 | import { ReviewPage } from "./pages/ReviewPage"; 6 | import { UserPage } from "./pages/UserPage"; 7 | 8 | expect.extend({ 9 | toBeOnLoginPage(page: Page) { 10 | if (page.url().match(/\/oidc\/realms\/demo\/protocol\/openid-connect\/auth/)) { 11 | return { 12 | message: () => "passed", 13 | pass: true, 14 | }; 15 | } 16 | 17 | return { 18 | message: () => `toBeOnLoginPage() assertion failed.\nExpected "/oidc/realms/demo/protocol/openid-connect/auth", got "${page.url()}".`, 19 | pass: false, 20 | }; 21 | }, 22 | }); 23 | 24 | type Test = { 25 | bookPage: BookPage, 26 | reviewPage: ReviewPage, 27 | userPage: UserPage, 28 | } 29 | 30 | export const test = playwrightTest.extend({ 31 | bookPage: async ({ page }, use) => { 32 | await use(new BookPage(page)); 33 | }, 34 | reviewPage: async ({ page }, use) => { 35 | await use(new ReviewPage(page)); 36 | }, 37 | userPage: async ({ page }, use) => { 38 | await use(new UserPage(page)); 39 | }, 40 | }); 41 | 42 | export { expect }; 43 | -------------------------------------------------------------------------------- /e2e/tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/e2e/tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg -------------------------------------------------------------------------------- /e2e/tests/mocks/openlibrary.org/books/OL2055137M.json: -------------------------------------------------------------------------------- 1 | { 2 | "publishers": [ 3 | "Doubleday" 4 | ], 5 | "number_of_pages": 481, 6 | "covers": [ 7 | 4066031, 8 | 238840 9 | ], 10 | "physical_format": "Hardcover", 11 | "lc_classifications": [ 12 | "PS3569.I47292 H97 1989", 13 | "PS3569.I47292H97" 14 | ], 15 | "url": [ 16 | "http://www.loc.gov/catdir/samples/random043/88033407.html", 17 | "http://www.loc.gov/catdir/description/random047/88033407.html" 18 | ], 19 | "key": "/books/OL2055137M", 20 | "authors": [ 21 | { 22 | "key": "/authors/OL235668A" 23 | } 24 | ], 25 | "publish_places": [ 26 | "New York" 27 | ], 28 | "uri_descriptions": [ 29 | "Sample text", 30 | "Publisher description" 31 | ], 32 | "edition_name": "1st ed.", 33 | "pagination": "481 p. ;", 34 | "classifications": {}, 35 | "source_records": [ 36 | "amazon:0385249497", 37 | "marc:marc_loc_2016/BooksAll.2016.part18.utf8:175194414:820", 38 | "bwb:9780385263481" 39 | ], 40 | "title": "Hyperion", 41 | "dewey_decimal_class": [ 42 | "813/.54" 43 | ], 44 | "notes": "\"A Foundation book.\"", 45 | "identifiers": { 46 | "librarything": [ 47 | "23078" 48 | ], 49 | "goodreads": [ 50 | "1718945", 51 | "2093816" 52 | ] 53 | }, 54 | "languages": [ 55 | { 56 | "key": "/languages/eng" 57 | } 58 | ], 59 | "lccn": [ 60 | "88033407" 61 | ], 62 | "isbn_10": [ 63 | "0385249497", 64 | "0385263481" 65 | ], 66 | "publish_date": "1989", 67 | "publish_country": "nyu", 68 | "by_statement": "Dan Simmons.", 69 | "works": [ 70 | { 71 | "key": "/works/OL1963268W" 72 | } 73 | ], 74 | "type": { 75 | "key": "/type/edition" 76 | }, 77 | "uris": [ 78 | "http://www.loc.gov/catdir/samples/random043/88033407.html", 79 | "http://www.loc.gov/catdir/description/random047/88033407.html" 80 | ], 81 | "latest_revision": 11, 82 | "revision": 11, 83 | "created": { 84 | "type": "/type/datetime", 85 | "value": "2008-04-01T03:28:50.625462" 86 | }, 87 | "last_modified": { 88 | "type": "/type/datetime", 89 | "value": "2021-09-29T01:39:35.554352" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /e2e/tests/pages/AbstractPage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | export abstract class AbstractPage { 4 | constructor(protected readonly page: Page) { 5 | } 6 | 7 | public async login() { 8 | await this.page.getByLabel("Email").fill("john.doe@example.com"); 9 | await this.page.getByLabel("Password").fill("Pa55w0rd"); 10 | await this.page.getByRole("button", { name: "Sign In" }).click(); 11 | if (await this.page.getByRole("button", { name: "Sign in with Keycloak" }).count()) { 12 | await this.page.getByRole("button", { name: "Sign in with Keycloak" }).click(); 13 | } 14 | 15 | return this.page; 16 | } 17 | 18 | public async getDefaultBook() { 19 | return this.page.getByTestId("book").filter({ hasText: "Hyperion" }).filter({ hasText: "Dan Simmons" }).first(); 20 | } 21 | 22 | public async waitForDefaultBookToBeLoaded() { 23 | await this.page.waitForResponse("https://openlibrary.org/books/OL2055137M.json"); 24 | await this.page.waitForResponse(/4066031-M\.jpg/); 25 | await (await this.getDefaultBook()).waitFor({ state: "visible" }); 26 | 27 | return this.page; 28 | } 29 | 30 | protected async registerMock() { 31 | await this.page.route(/^https:\/\/openlibrary\.org\/books\/(.+)\.json$/, (route) => route.fulfill({ 32 | path: "tests/mocks/openlibrary.org/books/OL2055137M.json" 33 | })); 34 | await this.page.route(/^https:\/\/openlibrary\.org\/works\/(.+)\.json$/, (route) => route.fulfill({ 35 | path: "tests/mocks/openlibrary.org/works/OL1963268W.json" 36 | })); 37 | await this.page.route("https://openlibrary.org/search.json?q=Foundation%20Isaac%20Asimov&limit=10", (route) => route.fulfill({ 38 | path: "tests/mocks/openlibrary.org/search/Foundation-Isaac-Asimov.json" 39 | })); 40 | await this.page.route("https://openlibrary.org/search.json?q=Eon%20Greg%20Bear&limit=10", (route) => route.fulfill({ 41 | path: "tests/mocks/openlibrary.org/search/Eon-Greg-Bear.json" 42 | })); 43 | await this.page.route("https://openlibrary.org/search.json?q=Hyperion%20Dan%20Simmons&limit=10", (route) => route.fulfill({ 44 | path: "tests/mocks/openlibrary.org/search/Hyperion-Dan-Simmons.json" 45 | })); 46 | await this.page.route(/^https:\/\/covers\.openlibrary.org\/b\/id\/(.+)\.jpg$/, (route) => route.fulfill({ 47 | path: "tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg", 48 | })); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /e2e/tests/pages/BookmarkPage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPage } from "./AbstractPage"; 2 | 3 | export class BookmarkPage extends AbstractPage { 4 | public async gotoList() { 5 | await this.registerMock(); 6 | 7 | await this.page.goto("/books"); 8 | await this.page.getByText("My Bookmarks").click(); 9 | await this.page.getByRole("button", { name: "Sign in with Keycloak" }).click(); 10 | await this.login(); 11 | await this.page.waitForURL(/\/bookmarks$/); 12 | await this.waitForDefaultBookToBeLoaded(); 13 | 14 | return this.page; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /e2e/tests/pages/UserPage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPage } from "./AbstractPage"; 2 | 3 | export class UserPage extends AbstractPage { 4 | } 5 | -------------------------------------------------------------------------------- /e2e/tests/test.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page, test as playwrightTest } from "@playwright/test"; 2 | 3 | import { BookPage } from "./pages/BookPage"; 4 | import { BookmarkPage } from "./pages/BookmarkPage"; 5 | import { UserPage } from "./pages/UserPage"; 6 | 7 | expect.extend({ 8 | toBeOnLoginPage(page: Page) { 9 | if (page.url().match(/\/oidc\/realms\/demo\/protocol\/openid-connect\/auth/)) { 10 | return { 11 | message: () => "passed", 12 | pass: true, 13 | }; 14 | } 15 | 16 | return { 17 | message: () => `toBeOnLoginPage() assertion failed.\nExpected "/oidc/realms/demo/protocol/openid-connect/auth", got "${page.url()}".`, 18 | pass: false, 19 | }; 20 | }, 21 | }); 22 | 23 | type Test = { 24 | bookPage: BookPage, 25 | bookmarkPage: BookmarkPage, 26 | userPage: UserPage, 27 | } 28 | 29 | export const test = playwrightTest.extend({ 30 | bookPage: async ({ page }, use) => { 31 | await use(new BookPage(page)); 32 | }, 33 | bookmarkPage: async ({ page }, use) => { 34 | await use(new BookmarkPage(page)); 35 | }, 36 | userPage: async ({ page }, use) => { 37 | await use(new UserPage(page)); 38 | }, 39 | }); 40 | 41 | export { expect }; 42 | -------------------------------------------------------------------------------- /helm/api-platform/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/api-platform/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | repository: https://charts.bitnami.com/bitnami/ 4 | version: 15.5.6 5 | - name: external-dns 6 | repository: https://charts.bitnami.com/bitnami/ 7 | version: 7.5.6 8 | - name: keycloak 9 | repository: https://charts.bitnami.com/bitnami/ 10 | version: 21.4.2 11 | digest: sha256:fa058d1558ec980b14354478fed4725d46b1f2a9b274af9ee7bee419944e926a 12 | generated: "2024-06-18T13:57:36.134642207+02:00" 13 | -------------------------------------------------------------------------------- /helm/api-platform/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: api-platform 3 | description: A Helm chart for an API Platform project 4 | home: https://api-platform.com 5 | icon: https://api-platform.com/logo-250x250.png 6 | 7 | # A chart can be either an 'application' or a 'library' chart. 8 | # 9 | # Application charts are a collection of templates that can be packaged into versioned archives 10 | # to be deployed. 11 | # 12 | # Library charts provide useful utilities or functions for the chart developer. They're included as 13 | # a dependency of application charts to inject those utilities and functions into the rendering 14 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 15 | type: application 16 | 17 | # This is the chart version. This version number should be incremented each time you make changes 18 | # to the chart and its templates, including the app version. 19 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 20 | version: 4.1.12 21 | 22 | # This is the version number of the application being deployed. This version number should be 23 | # incremented each time you make changes to the application. Versions are not expected to 24 | # follow Semantic Versioning. They should reflect the version the application is using. 25 | appVersion: 4.1.12 26 | 27 | dependencies: 28 | - name: postgresql 29 | version: ~15.5.6 30 | repository: https://charts.bitnami.com/bitnami/ 31 | condition: postgresql.enabled 32 | - name: external-dns 33 | version: ~7.5.6 34 | repository: https://charts.bitnami.com/bitnami/ 35 | condition: external-dns.enabled 36 | - name: keycloak 37 | version: ~21.4.2 38 | repository: https://charts.bitnami.com/bitnami/ 39 | condition: keycloak.enabled 40 | -------------------------------------------------------------------------------- /helm/api-platform/README.md: -------------------------------------------------------------------------------- 1 | # Deploying to a Kubernetes Cluster 2 | 3 | API Platform comes with native integration with [Kubernetes](https://kubernetes.io/) and the [Helm](https://helm.sh/) 4 | package manager. 5 | 6 | [Learn how to deploy in the dedicated documentation entry](https://api-platform.com/docs/deployment/kubernetes/). 7 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1.4 2 | 3 | 4 | 5 | 6 | # Versions 7 | FROM bitnami/keycloak:26-debian-12 AS keycloak_upstream 8 | 9 | 10 | # The different stages of this Dockerfile are meant to be built into separate images 11 | # https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage 12 | # https://docs.docker.com/compose/compose-file/#target 13 | 14 | 15 | # Keycloak image 16 | FROM keycloak_upstream AS keycloak 17 | 18 | COPY --link themes/api-platform-demo /opt/bitnami/keycloak/themes/api-platform-demo 19 | COPY --link providers/owner-policy.jar /opt/bitnami/keycloak/providers/owner-policy.jar 20 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/certs/tls.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDazCCAlOgAwIBAgIUGiIkdQOTG1A7NvXDLig7TVDsymIwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA4MjcxNjM5NTFaFw0yNDA4 5 | MjYxNjM5NTFaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQCvnrhtub8e1nwpRj7a0MDDnlH77BzANSVHLR54kB0x 8 | H4wFCIn7GIRXZsKr4xOH68obhtAFbXu+U6fK6ZkmHqhTtro/nU6eGGZhFK6eSVsg 9 | 4Hp07eoQ7oNsRX33nScZfpWo6YnH0yFwiUFM8oQW5rNBOUlJfwVONnPQcdGN6X6o 10 | lmuG2j0devmlKoohoIR/P/TNU/4X+PSvotjt1TyPFOeTWPwYvuOoYMiSFQSFNoRy 11 | c67N4Np88PGL84zl9FjZO9ndGje/CDgm3FJj7gswetNX7+4Ge3Q0121vfsvZ2M6k 12 | GTX6jwEysSyvbI0ewjX04IwuWjz1nXYgc4+XbC7N7pmdAgMBAAGjUzBRMB0GA1Ud 13 | DgQWBBT2Hy1/xksuYgSTATWa/O6aD39wvDAfBgNVHSMEGDAWgBT2Hy1/xksuYgST 14 | ATWa/O6aD39wvDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBF 15 | RDCteKuQ+RTEyzsAzslbPm+S/4T8bI+51Xuy+2ehidJtp0RXBk1VIcG2qG3rQdJF 16 | eTe4QbRZPC2g1CjnbBLkoMDJuGCY9a7b1Bc7pCS8oAfN5V044Uem7ydJuekoMEAR 17 | br95hojGe8RN/YOjOa/U9TazjCTo5JFi1UWg+WKK/r8YrBJJWsU+ORxCNMOaVgFp 18 | 286Gqdn+4aQo6bPx3xV0X/ny7Dq5i6FpvQkFopUcNpRUnYg+VpB01mNO6BsuduuG 19 | 3nmnDCo77LIOVvBuoRpRLaXsqDc7JbLeE1B9lRp1mbvPhTaDXotUglUIaASegA/d 20 | JwdqphZXvvEVMCufLfoY 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/providers/README.md: -------------------------------------------------------------------------------- 1 | # Building Providers 2 | 3 | Keycloak comes with a bunch of providers. 4 | 5 | To create a custom JavaScript Policy (https://www.keycloak.org/docs/24.0.1/server_development/#_script_providers), 6 | it must be packed in a JAR file. 7 | 8 | Build the provider as following: 9 | 10 | ```shell 11 | zip -r owner-policy.jar owner/* 12 | ``` 13 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/providers/owner-policy.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/helm/api-platform/keycloak/providers/owner-policy.jar -------------------------------------------------------------------------------- /helm/api-platform/keycloak/providers/owner/META-INF/keycloak-scripts.json: -------------------------------------------------------------------------------- 1 | { 2 | "policies": [ 3 | { 4 | "name": "Owner", 5 | "fileName": "owner-policy.js", 6 | "description": "Checks that a resource is owned by the current user" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/providers/owner/owner-policy.js: -------------------------------------------------------------------------------- 1 | var context = $evaluation.context; 2 | var identity = context.identity; 3 | var permission = $evaluation.permission; 4 | var resource = permission.resource; 5 | 6 | if (resource.owner == identity.id) { 7 | $evaluation.grant(); 8 | } 9 | -------------------------------------------------------------------------------- /helm/api-platform/keycloak/themes/api-platform-demo/login/theme.properties: -------------------------------------------------------------------------------- 1 | parent=keycloak 2 | import=common/keycloak 3 | -------------------------------------------------------------------------------- /helm/api-platform/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "api-platform.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "api-platform.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "api-platform.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "api-platform.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /helm/api-platform/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "api-platform.fullname" . }} 5 | labels: 6 | {{- include "api-platform.labels" . | nindent 4 }} 7 | data: 8 | php-app-env: {{ .Values.php.appEnv | quote }} 9 | php-app-debug: {{ .Values.php.appDebug | quote }} 10 | php-cors-allow-origin: {{ .Values.php.corsAllowOrigin | quote }} 11 | php-trusted-hosts: {{ .Values.php.trustedHosts | quote }} 12 | php-trusted-proxies: "{{ join "," .Values.php.trustedProxies }}" 13 | mercure-url: "http://{{ include "api-platform.fullname" . }}/.well-known/mercure" 14 | mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }} 15 | mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }} 16 | caddy-global-options: {{ .Values.php.caddyGlobalOptions | quote }} 17 | oidc-server-url: "https://{{ (first .Values.ingress.hosts).host }}/oidc/realms/demo" 18 | oidc-server-url-internal: "http://{{ template "common.names.fullname" .Subcharts.keycloak }}/oidc/realms/demo" 19 | next-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth" 20 | pwa-client-id: {{ .Values.pwa.oidcClientId | quote }} 21 | pwa-authorization-client-id: {{ .Values.php.oidcClientId | quote }} 22 | 23 | --- 24 | 25 | apiVersion: v1 26 | kind: ConfigMap 27 | metadata: 28 | name: keycloak-realm 29 | data: 30 | {{ (.Files.Glob "keycloak/config/*").AsConfig | indent 2 }} 31 | -------------------------------------------------------------------------------- /helm/api-platform/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "api-platform.fullname" . }} 6 | labels: 7 | {{- include "api-platform.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "api-platform.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 22 | type: Utilization 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 30 | type: Utilization 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /helm/api-platform/templates/pwa-hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "api-platform.fullname" . }}-pwa 6 | labels: 7 | {{- include "api-platform.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "api-platform.fullname" . }}-pwa 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 22 | type: Utilization 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 30 | type: Utilization 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /helm/api-platform/templates/pwa-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "api-platform.fullname" . }}-pwa 5 | labels: 6 | {{- include "api-platform.labelsPWA" . | nindent 4 }} 7 | spec: 8 | ports: 9 | - port: 3000 10 | targetPort: 3000 11 | protocol: TCP 12 | name: http 13 | selector: 14 | {{- include "api-platform.selectorLabelsPWA" . | nindent 4 }} 15 | -------------------------------------------------------------------------------- /helm/api-platform/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "api-platform.fullname" . }} 5 | labels: 6 | {{- include "api-platform.labels" . | nindent 4 }} 7 | type: Opaque 8 | data: 9 | {{- if .Values.postgresql.enabled }} 10 | database-url: {{ printf "pgsql://%s:%s@%s-postgresql/%s?serverVersion=14&charset=utf8" .Values.postgresql.global.postgresql.auth.username .Values.postgresql.global.postgresql.auth.password .Release.Name .Values.postgresql.global.postgresql.auth.database | b64enc | quote }} 11 | {{- else }} 12 | database-url: {{ .Values.postgresql.url | b64enc | quote }} 13 | {{- end }} 14 | php-app-secret: {{ .Values.php.appSecret | default (randAlphaNum 40) | b64enc | quote }} 15 | mercure-jwt-secret: {{ .Values.mercure.jwtSecret | default (randAlphaNum 40) | b64enc | quote }} 16 | next-auth-secret: {{ .Values.pwa.appSecret | default (randAlphaNum 40) | b64enc | quote }} 17 | -------------------------------------------------------------------------------- /helm/api-platform/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "api-platform.fullname" . }} 5 | labels: 6 | {{- include "api-platform.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "api-platform.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /helm/api-platform/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "api-platform.serviceAccountName" . }} 6 | labels: 7 | {{- include "api-platform.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/api-platform/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "api-platform.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "api-platform.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "api-platform.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /helm/skaffold-values.yaml: -------------------------------------------------------------------------------- 1 | service: 2 | type: NodePort 3 | -------------------------------------------------------------------------------- /helm/skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v4beta4 2 | kind: Config 3 | metadata: 4 | name: api-platform 5 | build: 6 | artifacts: 7 | - image: api-platform-php 8 | context: ../api 9 | docker: 10 | target: app_php 11 | - image: api-platform-pwa 12 | context: ../pwa 13 | docker: 14 | target: prod 15 | 16 | deploy: 17 | kubeContext: minikube 18 | helm: 19 | releases: 20 | - name: api-platform 21 | chartPath: ./api-platform 22 | namespace: default 23 | setValueTemplates: 24 | php.image.repository: "{{.IMAGE_REPO_api_platform_php}}" 25 | php.image.tag: "{{.IMAGE_TAG_api_platform_php}}@{{.IMAGE_DIGEST_api_platform_php}}" 26 | pwa.image.repository: "{{.IMAGE_REPO_api_platform_pwa}}" 27 | pwa.image.tag: "{{.IMAGE_TAG_api_platform_pwa}}@{{.IMAGE_DIGEST_api_platform_pwa}}" 28 | valuesFiles: 29 | - skaffold-values.yaml 30 | -------------------------------------------------------------------------------- /k6/script.js: -------------------------------------------------------------------------------- 1 | import { check, sleep } from "k6"; 2 | import http from "k6/http"; 3 | 4 | export let options = { 5 | maxRedirects: 0, 6 | scenarios: { 7 | default: { 8 | executor: 'per-vu-iterations', 9 | vus: 1, 10 | iterations: 1, 11 | } 12 | }, 13 | thresholds: { 14 | http_req_duration: [{threshold: 'p(95)<5000', abortOnFail: true}], //units in miliseconds 60000ms = 1m 15 | http_req_failed: [{threshold: 'rate<0.01', abortOnFail: true}], // http errors should be less than 1% 16 | checks: [{threshold: 'rate>0.95', abortOnFail: true}], // checks must success more than 99% 17 | }, 18 | }; 19 | 20 | const target=__ENV.TARGET; 21 | console.log(`Running test on ${target}`); 22 | 23 | export default function() { 24 | check_https_redirect(); 25 | check_api_link_header(); 26 | sleep(1); 27 | } 28 | 29 | function check_https_redirect() { 30 | var r = http.get(`http://${target}/`); 31 | check(r, { 32 | "http request: status is 301": (r) => r.status === 301, 33 | "http request: redirection location ok": (r) => r.headers["Location"] === `https://${target}/`, 34 | }); 35 | } 36 | 37 | function check_api_link_header() { 38 | var r = http.get(`https://${target}/books?page=1`); 39 | check(r, { 40 | "books API: status is 200": (r) => r.status === 200, 41 | "books API: Link in https": (r) => r.headers["Link"].match(`https://${target}/docs.jsonld`), 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /k6/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0) 4 | 5 | if [ -z "$TARGET" ]; then 6 | echo "Missing TARGET argument (e.g.: pr-123-demo.api-platform.com)" 1>&2 7 | exit 1 8 | fi 9 | 10 | docker run \ 11 | --name k6 \ 12 | --rm -i \ 13 | -v $(pwd):/test \ 14 | -w /test \ 15 | -p 5665:5665 \ 16 | -e TARGET=$TARGET \ 17 | ghcr.io/szkiba/xk6-dashboard:latest \ 18 | run \ 19 | --http-debug \ 20 | --out=dashboard \ 21 | ./script.js 22 | -------------------------------------------------------------------------------- /pwa/.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/*.md 3 | **/._* 4 | **/.dockerignore 5 | **/.DS_Store 6 | **/.git/ 7 | **/.gitattributes 8 | **/.gitignore 9 | **/.gitmodules 10 | **/compose.*.yaml 11 | **/compose.*.yml 12 | **/compose.yaml 13 | **/compose.yml 14 | **/docker-compose.*.yaml 15 | **/docker-compose.*.yml 16 | **/docker-compose.yaml 17 | **/docker-compose.yml 18 | **/Dockerfile 19 | **/Thumbs.db 20 | .next/ 21 | build/ 22 | node_modules/ 23 | .editorconfig 24 | .env.*.local 25 | .env.local 26 | -------------------------------------------------------------------------------- /pwa/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /pwa/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /pwa/Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1 2 | 3 | 4 | # Versions 5 | FROM node:lts AS node_upstream 6 | 7 | 8 | # Base stage for dev and build 9 | FROM node_upstream AS base 10 | 11 | WORKDIR /srv/app 12 | 13 | RUN npm install -g corepack@latest && \ 14 | corepack enable && \ 15 | corepack prepare --activate pnpm@latest && \ 16 | pnpm config -g set store-dir /.pnpm-store 17 | 18 | # Next.js collects completely anonymous telemetry data about general usage. 19 | # Learn more here: https://nextjs.org/telemetry 20 | # Delete the following line in case you want to enable telemetry during dev and build. 21 | ENV NEXT_TELEMETRY_DISABLED 1 22 | 23 | 24 | # Development image 25 | FROM base as dev 26 | 27 | EXPOSE 3000 28 | ENV PORT 3000 29 | ENV HOSTNAME localhost 30 | 31 | CMD ["sh", "-c", "pnpm install; pnpm dev"] 32 | 33 | 34 | FROM base AS builder 35 | 36 | COPY --link pnpm-lock.yaml ./ 37 | RUN pnpm fetch --prod 38 | 39 | COPY --link . . 40 | 41 | ARG AUTH_SECRET 42 | # https://nextjs.org/docs/app/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser 43 | ARG NEXT_PUBLIC_OIDC_SERVER_URL 44 | 45 | RUN pnpm install --frozen-lockfile --offline --prod && \ 46 | pnpm run build 47 | 48 | 49 | # Production image, copy all the files and run next 50 | FROM node_upstream AS prod 51 | 52 | WORKDIR /srv/app 53 | 54 | ENV NODE_ENV production 55 | # Delete the following line in case you want to enable telemetry during runtime. 56 | ENV NEXT_TELEMETRY_DISABLED 1 57 | 58 | RUN addgroup --system --gid 1001 nodejs; \ 59 | adduser --system --uid 1001 nextjs 60 | 61 | COPY --from=builder --link /srv/app/public ./public 62 | 63 | # Set the correct permission for prerender cache 64 | RUN mkdir .next; \ 65 | chown nextjs:nodejs .next 66 | 67 | # Automatically leverage output traces to reduce image size 68 | # https://nextjs.org/docs/advanced-features/output-file-tracing 69 | COPY --from=builder --link --chown=1001:1001 /srv/app/.next/standalone ./ 70 | COPY --from=builder --link --chown=1001:1001 /srv/app/.next/static ./.next/static 71 | 72 | USER nextjs 73 | 74 | EXPOSE 3000 75 | 76 | ENV PORT 3000 77 | ENV HOSTNAME "0.0.0.0" 78 | 79 | CMD ["node", "server.js"] 80 | -------------------------------------------------------------------------------- /pwa/README.md: -------------------------------------------------------------------------------- 1 | # Progressive Web App 2 | 3 | Contains a [Next.js](https://nextjs.org/) project bootstrapped with [pnpm](https://pnpm.io/) and [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 4 | 5 | The `admin` page contains an API Platform Admin project (refer to its [documentation](https://api-platform.com/docs/admin)). 6 | 7 | You can also generate your web app here by using the API Platform Client Generator (refer to its [documentation](https://api-platform.com/docs/client-generator/nextjs/)). 8 | -------------------------------------------------------------------------------- /pwa/app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | 5 | const Admin = dynamic(() => import("../../components/admin/Admin"), { 6 | ssr: false, 7 | }); 8 | 9 | const AdminPage = () => ( 10 | <> 11 | 12 | 21 | 22 | ); 23 | export default AdminPage; 24 | -------------------------------------------------------------------------------- /pwa/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "../../../auth"; 2 | -------------------------------------------------------------------------------- /pwa/app/bookmarks/page.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from "next"; 2 | import {redirect} from "next/navigation"; 3 | 4 | import {List, type Props as ListProps} from "../../components/bookmark/List"; 5 | import {type Bookmark} from "../../types/Bookmark"; 6 | import {type PagedCollection} from "../../types/collection"; 7 | import {fetchApi, type FetchResponse} from "../../utils/dataAccess"; 8 | import {auth, type Session} from "../auth"; 9 | 10 | interface Query extends URLSearchParams { 11 | page?: number|string|null; 12 | } 13 | 14 | export const metadata: Metadata = { 15 | title: 'Bookmarks', 16 | } 17 | async function getServerSideProps({ page = 1 }: Query, session: Session): Promise { 18 | try { 19 | const response: FetchResponse> | undefined = await fetchApi(`/bookmarks?page=${Number(page)}`, { 20 | // next: { revalidate: 3600 }, 21 | cache: "no-cache", 22 | }, session); 23 | if (!response?.data) { 24 | throw new Error('Unable to retrieve data from /bookmarks.'); 25 | } 26 | 27 | return { data: response.data, hubURL: response.hubURL, page: Number(page) }; 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | 32 | return { data: null, hubURL: null, page: Number(page) }; 33 | } 34 | 35 | export default async function Page({ searchParams }: { searchParams: Promise }) { 36 | // @ts-ignore 37 | const session: Session|null = await auth(); 38 | if (!session || session?.error === "RefreshAccessTokenError") { 39 | // todo find a way to redirect directly to keycloak from here 40 | // Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488 41 | redirect("/api/auth/signin?callbackUrl=/bookmarks"); 42 | } 43 | 44 | const props = await getServerSideProps(await searchParams, session); 45 | 46 | return ; 47 | } 48 | -------------------------------------------------------------------------------- /pwa/app/books/[id]/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from "next"; 2 | import {notFound} from "next/navigation"; 3 | 4 | import {type Props as ShowProps, Show} from "../../../../components/book/Show"; 5 | import {Book} from "../../../../types/Book"; 6 | import {fetchApi, type FetchResponse} from "../../../../utils/dataAccess"; 7 | import {auth, type Session} from "../../../auth"; 8 | 9 | interface Props { 10 | params: Promise<{ id: string }>; 11 | } 12 | 13 | export async function generateMetadata({ params }: Props): Promise { 14 | const id = (await params).id; 15 | try { 16 | const response: FetchResponse | undefined = await fetchApi(`/books/${id}`, { 17 | // next: { revalidate: 3600 }, 18 | cache: "no-cache", 19 | }); 20 | if (!response?.data) { 21 | throw new Error(`Unable to retrieve data from /books/${id}.`); 22 | } 23 | const item = response.data; 24 | 25 | return { 26 | title: `${item["title"]}${!!item["author"] && ` - ${item["author"]}`}`, 27 | }; 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | 32 | return undefined; 33 | } 34 | 35 | async function getServerSideProps(id: string, session: Session|null): Promise { 36 | try { 37 | const response: FetchResponse | undefined = await fetchApi(`/books/${id}`, { 38 | headers: { 39 | Preload: "/books/*/reviews", 40 | }, 41 | // next: { revalidate: 3600 }, 42 | cache: "no-cache", 43 | }, session); 44 | if (!response?.data) { 45 | throw new Error(`Unable to retrieve data from /books/${id}.`); 46 | } 47 | 48 | return { data: response.data, hubURL: response.hubURL }; 49 | } catch (error) { 50 | console.error(error); 51 | } 52 | 53 | return undefined; 54 | } 55 | 56 | export default async function Page({ params }: Props) { 57 | // @ts-ignore 58 | const session: Session|null = await auth(); 59 | const props = await getServerSideProps((await params).id, session); 60 | if (!props) { 61 | notFound(); 62 | } 63 | 64 | return ; 65 | } 66 | -------------------------------------------------------------------------------- /pwa/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type {Metadata} from "next"; 2 | import {type ReactNode} from "react"; 3 | import {SessionProvider} from "next-auth/react"; 4 | import "@fontsource/poppins"; 5 | import "@fontsource/poppins/600.css"; 6 | import "@fontsource/poppins/700.css"; 7 | 8 | import {Layout} from "../components/common/Layout"; 9 | import "../styles/globals.css"; 10 | import {Providers} from "./providers"; 11 | import {auth} from "./auth"; 12 | 13 | export const metadata: Metadata = { 14 | title: 'Welcome to API Platform!', 15 | } 16 | export default async function RootLayout({ children }: { children: ReactNode }) { 17 | const session = await auth(); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | {children} 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /pwa/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import {type ReactNode, useState} from "react"; 4 | import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; 5 | import {ReactQueryDevtools} from "@tanstack/react-query-devtools"; 6 | import {ReactQueryStreamedHydration} from "@tanstack/react-query-next-experimental"; 7 | 8 | export function Providers(props: { children: ReactNode }) { 9 | const [queryClient] = useState( 10 | () => 11 | new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | staleTime: 5 * 1000, 15 | }, 16 | }, 17 | }), 18 | ) 19 | 20 | return ( 21 | 22 | 23 | {props.children} 24 | 25 | {} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /pwa/components/admin/DocContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from "react"; 2 | 3 | const DocContext = createContext({ 4 | docType: "hydra", 5 | setDocType: (_docType: string) => {}, 6 | }); 7 | 8 | export default DocContext; 9 | -------------------------------------------------------------------------------- /pwa/components/admin/authProvider.tsx: -------------------------------------------------------------------------------- 1 | import {AuthProvider} from "react-admin"; 2 | import {getSession, signIn, signOut} from "next-auth/react"; 3 | 4 | import {NEXT_PUBLIC_OIDC_SERVER_URL} from "../../config/keycloak"; 5 | 6 | const authProvider: AuthProvider = { 7 | // Nothing to do here, this function will never be called 8 | login: async () => Promise.resolve(), 9 | logout: async () => { 10 | const session = getSession(); 11 | if (!session) { 12 | return; 13 | } 14 | 15 | await signOut({ 16 | // @ts-ignore 17 | callbackUrl: `${NEXT_PUBLIC_OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${window.location.origin}`, 18 | }); 19 | }, 20 | checkError: async (error) => { 21 | const session = getSession(); 22 | const status = error.status; 23 | // @ts-ignore 24 | if (!session || session?.error === "RefreshAccessTokenError" || status === 401) { 25 | await signIn("keycloak"); 26 | 27 | return; 28 | } 29 | 30 | if (status === 403) { 31 | return Promise.reject({ message: "Unauthorized user!", logoutUser: false }); 32 | } 33 | }, 34 | checkAuth: async () => { 35 | const session = getSession(); 36 | // @ts-ignore 37 | if (!session || session?.error === "RefreshAccessTokenError") { 38 | await signIn("keycloak"); 39 | 40 | return; 41 | } 42 | 43 | return Promise.resolve(); 44 | }, 45 | getPermissions: () => Promise.resolve(), 46 | getIdentity: async () => { 47 | const session = getSession(); 48 | 49 | // @ts-ignore 50 | return session ? Promise.resolve(session.user) : Promise.reject(); 51 | }, 52 | // Nothing to do here, this function will never be called 53 | handleCallback: () => Promise.resolve(), 54 | }; 55 | 56 | export default authProvider; 57 | -------------------------------------------------------------------------------- /pwa/components/admin/book/BookForm.tsx: -------------------------------------------------------------------------------- 1 | import {required} from "react-admin"; 2 | 3 | import {ConditionInput} from "./ConditionInput"; 4 | import {BookInput} from "./BookInput"; 5 | 6 | export const BookForm = () => ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /pwa/components/admin/book/BooksCreate.tsx: -------------------------------------------------------------------------------- 1 | import {CreateGuesser, type CreateGuesserProps} from "@api-platform/admin"; 2 | 3 | import {BookForm} from "./BookForm"; 4 | 5 | export const BooksCreate = (props: CreateGuesserProps) => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /pwa/components/admin/book/BooksEdit.tsx: -------------------------------------------------------------------------------- 1 | import {EditGuesser} from "@api-platform/admin"; 2 | import {TopToolbar} from "react-admin"; 3 | 4 | import {BookForm} from "./BookForm"; 5 | import {ShowButton} from "./ShowButton"; 6 | 7 | // @ts-ignore 8 | const Actions = () => ( 9 | 10 | 11 | 12 | ); 13 | export const BooksEdit = () => ( 14 | // @ts-ignore 15 | }> 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /pwa/components/admin/book/BooksList.tsx: -------------------------------------------------------------------------------- 1 | import {FieldGuesser} from "@api-platform/admin"; 2 | import {Datagrid, EditButton, List, TextInput, useRecordContext, WrapperField,} from "react-admin"; 3 | 4 | import {ShowButton} from "./ShowButton"; 5 | import {RatingField} from "../review/RatingField"; 6 | import {ConditionInput} from "./ConditionInput"; 7 | 8 | const ConditionField = () => { 9 | const record = useRecordContext(); 10 | if (!record || !record.condition) return null; 11 | return ( 12 | 13 | {record.condition.replace(/https:\/\/schema\.org\/(.+)Condition$/, "$1")} 14 | 15 | ); 16 | }; 17 | 18 | const filters = [ 19 | , 20 | , 21 | , 22 | ]; 23 | 24 | export const BooksList = () => ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | -------------------------------------------------------------------------------- /pwa/components/admin/book/ConditionInput.tsx: -------------------------------------------------------------------------------- 1 | import {SelectInput, SelectInputProps} from "react-admin"; 2 | 3 | export const ConditionInput = (props: SelectInputProps) => ( 4 | 14 | ); 15 | -------------------------------------------------------------------------------- /pwa/components/admin/book/ShowButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button, useRecordContext} from "react-admin"; 2 | import slugify from "slugify"; 3 | import VisibilityIcon from "@mui/icons-material/Visibility"; 4 | 5 | import {getItemPath} from "../../../utils/dataAccess"; 6 | 7 | export const ShowButton = () => { 8 | const record = useRecordContext(); 9 | return record ? ( 10 | 28 | ) : null; 29 | }; 30 | -------------------------------------------------------------------------------- /pwa/components/admin/book/index.ts: -------------------------------------------------------------------------------- 1 | import {BooksList} from "./BooksList"; 2 | import {BooksCreate} from "./BooksCreate"; 3 | import {BooksEdit} from "./BooksEdit"; 4 | import {type Book} from "../../../types/Book"; 5 | 6 | const bookResourceProps = { 7 | list: BooksList, 8 | create: BooksCreate, 9 | edit: BooksEdit, 10 | hasShow: false, 11 | recordRepresentation: (record: Book) => `${record.title} - ${record.author}`, 12 | }; 13 | 14 | export default bookResourceProps; 15 | -------------------------------------------------------------------------------- /pwa/components/admin/i18nProvider.ts: -------------------------------------------------------------------------------- 1 | import {resolveBrowserLocale} from "react-admin"; 2 | import polyglotI18nProvider from "ra-i18n-polyglot"; 3 | import englishMessages from "ra-language-english"; 4 | import frenchMessages from "ra-language-french"; 5 | 6 | const messages = { 7 | fr: frenchMessages, 8 | en: englishMessages, 9 | }; 10 | const i18nProvider = polyglotI18nProvider( 11 | // @ts-ignore 12 | (locale) => (messages[locale] ? messages[locale] : messages.en), 13 | resolveBrowserLocale() 14 | ); 15 | 16 | export default i18nProvider; 17 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import {AppBar, TitlePortal, UserMenu} from "react-admin"; 2 | 3 | import Logo from "../Logo"; 4 | import Logout from "./Logout"; 5 | import DocTypeMenuButton from "./DocTypeMenuButton"; 6 | 7 | const CustomAppBar = () => ( 8 | 11 | 12 | 13 | } 14 | > 15 | 16 |
17 | 18 |
19 | 20 |
21 | ); 22 | 23 | export default CustomAppBar; 24 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/DocTypeMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import {useContext, useState} from "react"; 2 | import {useStore} from "react-admin"; 3 | import {Button, Menu, MenuItem} from "@mui/material"; 4 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 5 | 6 | import DocContext from "../DocContext"; 7 | import HydraLogo from "./HydraLogo"; 8 | import OpenApiLogo from "./OpenApiLogo"; 9 | 10 | const DocTypeMenuButton = () => { 11 | const [anchorEl, setAnchorEl] = useState(null); 12 | const [, setStoreDocType] = useStore("docType", "hydra"); 13 | const { docType, setDocType } = useContext(DocContext); 14 | 15 | const open = Boolean(anchorEl); 16 | // @ts-ignore 17 | const handleClick = (event) => { 18 | setAnchorEl(event.currentTarget); 19 | }; 20 | const handleClose = () => { 21 | setAnchorEl(null); 22 | }; 23 | const changeDocType = (docType: string) => { 24 | setStoreDocType(docType); 25 | setDocType(docType); 26 | handleClose(); 27 | }; 28 | 29 | return ( 30 | <> 31 | 42 | 51 | changeDocType("hydra")}>Hydra 52 | changeDocType("openapi")}>OpenAPI 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default DocTypeMenuButton; 59 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/HydraLogo.tsx: -------------------------------------------------------------------------------- 1 | const HydraLogo = () => ( 2 | 10 | 11 | 18 | 19 | 20 | 21 | 25 | 32 | 33 | 34 | 35 | 36 | 40 | 47 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | ); 58 | 59 | export default HydraLogo; 60 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import {Layout, type LayoutProps} from "react-admin"; 2 | import AppBar from "./AppBar"; 3 | import Menu from "./Menu"; 4 | 5 | const MyLayout = (props: React.JSX.IntrinsicAttributes & LayoutProps) => ( 6 | 7 | ); 8 | 9 | export default MyLayout; 10 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/Logout.tsx: -------------------------------------------------------------------------------- 1 | import {ForwardedRef, forwardRef} from "react"; 2 | import {LogoutClasses, useTranslate} from "react-admin"; 3 | 4 | import {ListItemIcon, ListItemText, MenuItem} from "@mui/material"; 5 | import ExitIcon from "@mui/icons-material/PowerSettingsNew"; 6 | import {signOut, useSession} from "next-auth/react"; 7 | 8 | import {NEXT_PUBLIC_OIDC_SERVER_URL} from "../../../config/keycloak"; 9 | 10 | const Logout = forwardRef((props, ref: ForwardedRef) => { 11 | const { data: session } = useSession(); 12 | const translate = useTranslate(); 13 | 14 | if (!session) { 15 | return; 16 | } 17 | 18 | const handleClick = () => 19 | signOut({ 20 | // @ts-ignore 21 | callbackUrl: `${NEXT_PUBLIC_OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${window.location.origin}`, 22 | }); 23 | 24 | return ( 25 | 32 | 33 | 34 | 35 | 36 | {translate("ra.auth.logout", { _: "Logout" })} 37 | 38 | 39 | ); 40 | }); 41 | Logout.displayName = "Logout"; 42 | 43 | export default Logout; 44 | -------------------------------------------------------------------------------- /pwa/components/admin/layout/Menu.tsx: -------------------------------------------------------------------------------- 1 | import {Menu} from "react-admin"; 2 | import MenuBookIcon from "@mui/icons-material/MenuBook"; 3 | import CommentIcon from "@mui/icons-material/Comment"; 4 | 5 | const CustomMenu = () => ( 6 | 7 | } 11 | /> 12 | } 16 | /> 17 | 18 | ); 19 | export default CustomMenu; 20 | -------------------------------------------------------------------------------- /pwa/components/admin/review/BookField.tsx: -------------------------------------------------------------------------------- 1 | import {useRecordContext, type UseRecordContextParams} from "react-admin"; 2 | import Link from "next/link"; 3 | import slugify from "slugify"; 4 | 5 | import {getItemPath} from "../../../utils/dataAccess"; 6 | 7 | export const BookField = (props: UseRecordContextParams) => { 8 | const record = useRecordContext(props); 9 | if (!record || !record.book) return null; 10 | return ( 11 | 25 | {record.book.title} - {record.book.author} 26 | 27 | ); 28 | }; 29 | BookField.defaultProps = { label: "Book" }; 30 | -------------------------------------------------------------------------------- /pwa/components/admin/review/RatingField.tsx: -------------------------------------------------------------------------------- 1 | import {useRecordContext} from "react-admin"; 2 | import Rating from "@mui/material/Rating"; 3 | 4 | export const RatingField = () => { 5 | const record = useRecordContext(); 6 | return !!record && typeof record.rating === "number" ? ( 7 | 8 | ) : null; 9 | }; 10 | -------------------------------------------------------------------------------- /pwa/components/admin/review/RatingInput.tsx: -------------------------------------------------------------------------------- 1 | import {Labeled, useInput} from "react-admin"; 2 | import {type CommonInputProps, type ResettableTextFieldProps,} from "ra-ui-materialui"; 3 | import Rating, {type RatingProps} from "@mui/material/Rating"; 4 | 5 | export type RatingInputProps = RatingProps & 6 | CommonInputProps & 7 | Omit; 8 | 9 | export const RatingInput = (props: RatingInputProps) => { 10 | const { 11 | field: { ref, ...field }, 12 | } = useInput(props); 13 | const value = Number(field.value); 14 | // Error with "helperText" and "validate" props: remove them from the Rating component 15 | const { helperText, validate, ...rest } = props; 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | RatingInput.displayName = "RatingInput"; 24 | -------------------------------------------------------------------------------- /pwa/components/admin/review/ReviewsEdit.tsx: -------------------------------------------------------------------------------- 1 | import {EditGuesser} from "@api-platform/admin"; 2 | import {AutocompleteInput, ReferenceInput, required, TextInput,} from "react-admin"; 3 | 4 | import {type Book} from "../../../types/Book"; 5 | import {type Review} from "../../../types/Review"; 6 | import {RatingInput} from "./RatingInput"; 7 | 8 | const transform = (data: Review) => ({ 9 | ...data, 10 | book: data.book["@id"], 11 | rating: Number(data.rating), 12 | }); 13 | 14 | export const ReviewsEdit = () => ( 15 | 16 | 17 | ({ title: searchText })} 19 | optionText={(choice: Book): string => 20 | `${choice.title} - ${choice.author}` 21 | } 22 | label="Book" 23 | style={{ width: 500 }} 24 | validate={required()} 25 | /> 26 | 27 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /pwa/components/admin/review/ReviewsList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AutocompleteInput, 3 | Datagrid, 4 | DateField, 5 | EditButton, 6 | List, 7 | ListActions, 8 | ReferenceInput, 9 | ShowButton, 10 | TextField, 11 | WrapperField, 12 | } from "react-admin"; 13 | 14 | import {BookField} from "./BookField"; 15 | import {RatingField} from "./RatingField"; 16 | import {RatingInput} from "./RatingInput"; 17 | import {type Book} from "../../../types/Book"; 18 | import {User} from "../../../types/User"; 19 | 20 | const bookQuery = (searchText: string) => { 21 | const values = searchText 22 | .split(" - ") 23 | .map((n) => n.trim()) 24 | .filter((n) => n); 25 | const query = { title: values[0] }; 26 | if (typeof values[1] !== "undefined") { 27 | // @ts-ignore 28 | query.author = values[1]; 29 | } 30 | 31 | return query; 32 | }; 33 | 34 | const filters = [ 35 | 36 | 39 | `${choice.title} - ${choice.author}` 40 | } 41 | name="book" 42 | style={{ width: 300 }} 43 | /> 44 | , 45 | 46 | ({ name: searchText })} 48 | optionText={(choice: User): string => choice.name} 49 | name="user" 50 | style={{ width: 300 }} 51 | /> 52 | , 53 | , 54 | ]; 55 | 56 | export const ReviewsList = () => ( 57 | } title="Reviews"> 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | -------------------------------------------------------------------------------- /pwa/components/admin/review/ReviewsShow.tsx: -------------------------------------------------------------------------------- 1 | import {FieldGuesser, ShowGuesser} from "@api-platform/admin"; 2 | import {Labeled, TextField} from "react-admin"; 3 | 4 | import {RatingField} from "./RatingField"; 5 | import {BookField} from "./BookField"; 6 | 7 | export const ReviewsShow = () => ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /pwa/components/admin/review/index.ts: -------------------------------------------------------------------------------- 1 | import {ReviewsList} from "./ReviewsList"; 2 | import {ReviewsEdit} from "./ReviewsEdit"; 3 | import {ReviewsShow} from "./ReviewsShow"; 4 | import {type Review} from "../../../types/Review"; 5 | 6 | const reviewResourceProps = { 7 | list: ReviewsList, 8 | edit: ReviewsEdit, 9 | show: ReviewsShow, 10 | hasCreate: false, 11 | recordRepresentation: (record: Review) => record.user.name, 12 | }; 13 | 14 | export default reviewResourceProps; 15 | -------------------------------------------------------------------------------- /pwa/components/book/Item.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import {type FunctionComponent} from "react"; 4 | import Rating from "@mui/material/Rating"; 5 | 6 | import {type Book} from "../../types/Book"; 7 | import {getItemPath} from "../../utils/dataAccess"; 8 | import {useOpenLibraryBook} from "../../utils/book"; 9 | import {Loading} from "../common/Loading"; 10 | 11 | interface Props { 12 | book: Book; 13 | } 14 | 15 | export const Item: FunctionComponent = ({ book }) => { 16 | const { data, isLoading } = useOpenLibraryBook(book); 17 | 18 | if (isLoading || !data) return ; 19 | 20 | return ( 21 |
22 |
23 | 24 | {!!data["images"] && ( 25 | {data["title"]} 28 | ) || ( 29 | No cover 30 | )} 31 | 32 |
33 |
34 |

35 | 37 | {data["title"]} 38 | 39 |

40 |

41 | 42 | {data["author"]} 43 | 44 |

45 | {!!data["rating"] && ( 46 | 47 | )} 48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /pwa/components/bookmark/List.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import {type NextPage} from "next"; 4 | 5 | import {Item} from "../../components/book/Item"; 6 | import {Pagination} from "../../components/common/Pagination"; 7 | import {type Bookmark} from "../../types/Bookmark"; 8 | import {type PagedCollection} from "../../types/collection"; 9 | import {useMercure} from "../../utils/mercure"; 10 | 11 | export interface Props { 12 | data: PagedCollection | null; 13 | hubURL: string | null; 14 | page: number; 15 | } 16 | 17 | const getPagePath = (page: number): string => `/bookmarks?page=${page}`; 18 | 19 | export const List: NextPage = ({ data, hubURL, page }) => { 20 | const collection = useMercure(data, hubURL); 21 | 22 | return ( 23 |
24 | {!!collection && !!collection["hydra:member"] && ( 25 | <> 26 |

27 | {collection["hydra:totalItems"]} book(s) bookmarked 28 |

29 |
30 | {collection["hydra:member"].length !== 0 && collection["hydra:member"].map((bookmark) => ( 31 | 32 | ))} 33 |
34 | 35 | 36 | ) || ( 37 |

No bookmarks found.

38 | )} 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /pwa/components/common/Error.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | message: string; 3 | } 4 | 5 | export const Error = ({ message }: Props) => ( 6 | 26 | ); 27 | -------------------------------------------------------------------------------- /pwa/components/common/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import {signIn, signOut, useSession} from "next-auth/react"; 4 | import {usePathname} from "next/navigation"; 5 | import Link from "next/link"; 6 | import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; 7 | import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; 8 | 9 | import {NEXT_PUBLIC_OIDC_SERVER_URL} from "../../config/keycloak"; 10 | 11 | export const Header = () => { 12 | const pathname = usePathname(); 13 | const { data: session, status } = useSession(); 14 | 15 | if (pathname === "/" || pathname.match(/^\/admin/)) return <>; 16 | 17 | return ( 18 |
19 | 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /pwa/components/common/Layout.tsx: -------------------------------------------------------------------------------- 1 | import {type ReactNode} from "react"; 2 | 3 | import {Header} from "./Header"; 4 | 5 | export const Layout = ({ children }: { children: ReactNode }) => { 6 | return ( 7 | <> 8 |
9 | {children} 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /pwa/components/common/Loading.tsx: -------------------------------------------------------------------------------- 1 | export const Loading = () => ( 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ); 16 | -------------------------------------------------------------------------------- /pwa/components/common/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import MuiPagination from "@mui/material/Pagination"; 2 | import {PaginationItem} from "@mui/material"; 3 | import Link from "next/link"; 4 | 5 | import {type PagedCollection} from "../../types/collection"; 6 | import {parsePage} from "../../utils/dataAccess"; 7 | 8 | interface Props { 9 | collection: PagedCollection; 10 | getPagePath: (page: number) => string; 11 | currentPage: number; 12 | } 13 | 14 | export const Pagination = ({ collection, getPagePath, currentPage }: Props) => { 15 | const view = collection && collection["hydra:view"]; 16 | if (!view || !view["hydra:last"]) return null; 17 | 18 | return ( 19 |
20 |
21 |
22 | ( 24 | 25 | )} 26 | /> 27 |
28 |
29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /pwa/config/entrypoint.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | export const ENTRYPOINT: string = typeof window === "undefined" ? process.env.NEXT_PUBLIC_ENTRYPOINT : window.origin; 3 | -------------------------------------------------------------------------------- /pwa/config/keycloak.ts: -------------------------------------------------------------------------------- 1 | export const NEXT_PUBLIC_OIDC_CLIENT_ID: string = process.env.NEXT_PUBLIC_OIDC_CLIENT_ID || 'api-platform-pwa'; 2 | export const NEXT_PUBLIC_OIDC_SERVER_URL: string = process.env.NEXT_PUBLIC_OIDC_SERVER_URL || 'https://localhost/oidc/realms/demo'; 3 | export const NEXT_PUBLIC_OIDC_SERVER_URL_INTERNAL: string = process.env.NEXT_PUBLIC_OIDC_SERVER_URL_INTERNAL || 'http://keycloak:8080/oidc/realms/demo'; 4 | export const NEXT_PUBLIC_OIDC_AUTHORIZATION_CLIENT_ID: string = process.env.NEXT_PUBLIC_OIDC_AUTHORIZATION_CLIENT_ID || 'api-platform-api'; 5 | -------------------------------------------------------------------------------- /pwa/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /pwa/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | output: 'standalone', 5 | images: { 6 | minimumCacheTTL: 3600, 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: 'covers.openlibrary.org', 11 | port: '', 12 | pathname: '/b/id/**' 13 | } 14 | ] 15 | } 16 | } 17 | 18 | module.exports = nextConfig; 19 | -------------------------------------------------------------------------------- /pwa/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwa", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@api-platform/admin": "^4.0.6", 13 | "@api-platform/api-doc-parser": "^0.16.8", 14 | "@auth/core": "^0.38.0", 15 | "@fontsource/poppins": "^5.2.5", 16 | "@mui/icons-material": "latest-v5", 17 | "@mui/material": "latest-v5", 18 | "@tailwindcss/forms": "^0.5.10", 19 | "@tanstack/react-query": "^5.74.2", 20 | "@tanstack/react-query-devtools": "^5.74.2", 21 | "@tanstack/react-query-next-experimental": "^5.74.2", 22 | "autoprefixer": "^10.4.21", 23 | "formik": "^2.4.6", 24 | "next": "^15.3.0", 25 | "next-auth": "5.0.0-beta.25", 26 | "picocolors": "^1.1.1", 27 | "postcss": "^8.5.3", 28 | "ra-i18n-polyglot": "^5.7.2", 29 | "ra-language-english": "^5.7.2", 30 | "ra-language-french": "^5.7.2", 31 | "ra-ui-materialui": "^5.7.2", 32 | "react": "^19.1.0", 33 | "react-admin": "^5.7.2", 34 | "react-dom": "^19.1.0", 35 | "react-hook-form": "^7.55.0", 36 | "react-spinners": "^0.15.0", 37 | "sharp": "^0.34.1", 38 | "slugify": "^1.6.6", 39 | "tailwindcss": "^3.4.17", 40 | "yup": "^1.6.1" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.26.10", 44 | "@popperjs/core": "^2.11.8", 45 | "@types/node": "^22.14.1", 46 | "@types/react": "^19.1.1", 47 | "@types/react-dom": "^19.1.2", 48 | "eslint": "^9.24.0", 49 | "eslint-config-next": "^15.3.0", 50 | "typescript": "^5.8.3" 51 | }, 52 | "packageManager": "pnpm@10.3.0" 53 | } 54 | -------------------------------------------------------------------------------- /pwa/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /pwa/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /pwa/public/api-platform/admin.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pwa/public/api-platform/api.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pwa/public/api-platform/mercure.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pwa/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/demo/b4f0d877152a06b4ed016c656222766c8025a7a3/pwa/public/favicon.ico -------------------------------------------------------------------------------- /pwa/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /pwa/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | * { 7 | font-family: Poppins; 8 | } 9 | 10 | a { 11 | @apply no-underline text-cyan-700; 12 | } 13 | } 14 | 15 | @layer utilities { 16 | .bg-spider-cover { 17 | @apply bg-cyan-700 bg-center bg-no-repeat; 18 | background-image: url("../public/api-platform/web.svg"); 19 | background-size: 110%; 20 | } 21 | .ribbon { 22 | position: absolute; 23 | top: 0; 24 | right: 0; 25 | transform: translate(13.397459%, -100%) rotate(30deg); /* translateX: 100%*(1-cos(angleRotation) */ 26 | transform-origin: bottom left; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pwa/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ["Poppins", "system-ui"], 12 | }, 13 | boxShadow: { 14 | card: "0px 0px 20px 0px rgba(0, 0, 0, 0.15)", 15 | }, 16 | transitionDuration: { 17 | DEFAULT: "300ms", 18 | }, 19 | minHeight: { 20 | 24: "96px" 21 | }, 22 | colors: { 23 | cyan: { 24 | 500: "#46b6bf", 25 | 700: "#0f929a", 26 | 200: "#bceff3" 27 | }, 28 | red: { 29 | 500: "#ee4322" 30 | }, 31 | black: "#1d1e1c", 32 | white: "#ffffff", 33 | transparent: "transparent", 34 | }, 35 | }, 36 | container: { 37 | padding: "2rem", 38 | center: true, 39 | }, 40 | }, 41 | plugins: [ 42 | require("@tailwindcss/forms"), 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /pwa/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /pwa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "**/*.ts", 29 | "**/*.tsx", 30 | "next-env.d.ts", 31 | ".next/types/**/*.ts" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /pwa/types/Book.ts: -------------------------------------------------------------------------------- 1 | import { type Item } from "./item"; 2 | import { type Thumbnails } from "./Thumbnails"; 3 | 4 | export class Book implements Item { 5 | public "@id"?: string; 6 | 7 | constructor( 8 | public book: string, 9 | public title: string, 10 | public condition: string, 11 | public reviews: string, 12 | public author?: string, 13 | public rating?: number, 14 | _id?: string, 15 | public id?: string, 16 | public slug?: string, 17 | public images?: Thumbnails, 18 | public description?: string, 19 | public publicationDate?: string, 20 | ) { 21 | this["@id"] = _id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pwa/types/Bookmark.ts: -------------------------------------------------------------------------------- 1 | import { type Item } from "./item"; 2 | import { type Book } from "./Book"; 3 | 4 | export class Bookmark implements Item { 5 | public "@id"?: string; 6 | 7 | constructor( 8 | public book: Book, 9 | public bookmarkedAt: Date, 10 | _id?: string, 11 | ) { 12 | this["@id"] = _id; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pwa/types/Gutendex/Search.ts: -------------------------------------------------------------------------------- 1 | import { SearchDoc } from "./SearchDoc"; 2 | 3 | export class Search { 4 | constructor( 5 | public results: Array, 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pwa/types/Gutendex/SearchDoc.ts: -------------------------------------------------------------------------------- 1 | export class SearchDoc { 2 | constructor( 3 | public id?: number, 4 | public title?: string, 5 | public authors?: Array, 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/Book.ts: -------------------------------------------------------------------------------- 1 | import { type Description } from "./Description"; 2 | import { type Item } from "./Item"; 3 | 4 | export class Book { 5 | constructor( 6 | public description?: string | Description, 7 | public publish_date?: string, 8 | public covers?: Array, 9 | public works?: Array, 10 | ) { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/Description.ts: -------------------------------------------------------------------------------- 1 | export class Description { 2 | constructor( 3 | public value: string, 4 | ) { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/Item.ts: -------------------------------------------------------------------------------- 1 | export class Item { 2 | constructor( 3 | public key: string, 4 | ) { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/Search.ts: -------------------------------------------------------------------------------- 1 | import { SearchDoc } from "./SearchDoc"; 2 | 3 | export class Search { 4 | constructor( 5 | public docs: Array, 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/SearchDoc.ts: -------------------------------------------------------------------------------- 1 | export class SearchDoc { 2 | constructor( 3 | public title?: string, 4 | public author_name?: Array, 5 | public seed?: Array, 6 | ) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pwa/types/OpenLibrary/Work.ts: -------------------------------------------------------------------------------- 1 | import { type Description } from "./Description"; 2 | 3 | export class Work { 4 | constructor( 5 | public description?: string | Description, 6 | public covers?: Array, 7 | ) { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pwa/types/Review.ts: -------------------------------------------------------------------------------- 1 | import { type Item } from "./item"; 2 | import { type User } from "./User"; 3 | import { type Book } from "./Book"; 4 | 5 | export class Review implements Item { 6 | public "@id"?: string; 7 | 8 | constructor( 9 | public body: string, 10 | public rating: number, 11 | public book: Book, 12 | public user: User, 13 | public publishedAt: Date, 14 | _id?: string, 15 | ) { 16 | this["@id"] = _id; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pwa/types/Thumbnails.ts: -------------------------------------------------------------------------------- 1 | export class Thumbnails { 2 | constructor( 3 | public medium: string, 4 | public large: string, 5 | ) { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pwa/types/User.ts: -------------------------------------------------------------------------------- 1 | import { type Item } from "./item"; 2 | 3 | export class User implements Item { 4 | public "@id"?: string; 5 | 6 | constructor( 7 | public firstName: string, 8 | public lastName: string, 9 | public name: string, 10 | public sub: string, 11 | _id?: string, 12 | ) { 13 | this["@id"] = _id; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pwa/types/collection.ts: -------------------------------------------------------------------------------- 1 | export interface PagedCollection { 2 | "@context"?: string; 3 | "@id"?: string; 4 | "@type"?: string; 5 | "hydra:member"?: T[]; 6 | "hydra:search"?: object; 7 | "hydra:totalItems"?: number; 8 | "hydra:view"?: { 9 | "@id": string; 10 | "@type": string; 11 | "hydra:first"?: string; 12 | "hydra:last"?: string; 13 | "hydra:previous"?: string; 14 | "hydra:next"?: string; 15 | }; 16 | } 17 | 18 | export const isPagedCollection = (data: any): data is PagedCollection => 19 | "hydra:member" in data && Array.isArray(data["hydra:member"]); 20 | -------------------------------------------------------------------------------- /pwa/types/item.ts: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | "@id"?: string; 3 | } 4 | 5 | export const isItem = (data: any): data is Item => "@id" in data; 6 | -------------------------------------------------------------------------------- /pwa/utils/mercure.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { type Item } from "../types/item"; 3 | import { type PagedCollection } from "../types/collection"; 4 | import { isItem } from "../types/item"; 5 | import { isPagedCollection } from "../types/collection"; 6 | 7 | const mercureSubscribe = | null | undefined>( 8 | hubURL: string, 9 | data: T | PagedCollection, 10 | setData: (data: T) => void 11 | ) => { 12 | if (!data || !data["@id"]) throw new Error("@id is missing"); 13 | 14 | const url = new URL(hubURL, window.origin); 15 | url.searchParams.append( 16 | "topic", 17 | new URL(data["@id"], window.origin).toString() 18 | ); 19 | const eventSource = new EventSource(url.toString()); 20 | eventSource.addEventListener("message", (event) => 21 | setData(JSON.parse(event.data)) 22 | ); 23 | 24 | return eventSource; 25 | }; 26 | 27 | export const useMercure = < 28 | TData extends Item | PagedCollection | null | undefined 29 | >( 30 | deps: TData, 31 | hubURL: string | null | undefined 32 | ): TData => { 33 | const [data, setData] = useState(deps); 34 | 35 | useEffect(() => { 36 | setData(deps); 37 | }, [deps]); 38 | 39 | useEffect(() => { 40 | if (!hubURL || !data) { 41 | return; 42 | } 43 | 44 | if (!isPagedCollection(data) && !isItem(data)) { 45 | console.error("Object sent is not in JSON-LD format."); 46 | 47 | return; 48 | } 49 | 50 | if ( 51 | isPagedCollection(data) && 52 | data["hydra:member"] && 53 | data["hydra:member"].length !== 0 54 | ) { 55 | const eventSources: EventSource[] = []; 56 | // It's a PagedCollection 57 | data["hydra:member"].forEach((obj, pos) => { 58 | eventSources.push( 59 | mercureSubscribe(hubURL, obj, (datum) => { 60 | if (data["hydra:member"]) { 61 | data["hydra:member"][pos] = datum; 62 | } 63 | setData({ ...data }); 64 | }) 65 | ); 66 | }); 67 | 68 | return () => { 69 | eventSources.forEach((eventSource) => eventSource.close()); 70 | }; 71 | } 72 | 73 | // It's a single object 74 | const eventSource = mercureSubscribe(hubURL, data, setData); 75 | 76 | return () => { 77 | eventSource.close(); 78 | }; 79 | }, [data]); 80 | 81 | return data; 82 | }; 83 | -------------------------------------------------------------------------------- /pwa/utils/review.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { type Session } from "../app/auth"; 4 | import { type Review } from "../types/Review"; 5 | import { NEXT_PUBLIC_OIDC_AUTHORIZATION_CLIENT_ID, NEXT_PUBLIC_OIDC_SERVER_URL } from "../config/keycloak"; 6 | 7 | interface Permission { 8 | result: boolean; 9 | } 10 | 11 | export const usePermission = (review: Review, session: Session|null): boolean => { 12 | const [isGranted, grant] = useState(false); 13 | 14 | useEffect(() => { 15 | if (!session) { 16 | return; 17 | } 18 | 19 | (async () => { 20 | try { 21 | const response = await fetch(`${NEXT_PUBLIC_OIDC_SERVER_URL}/protocol/openid-connect/token`, { 22 | headers: { 23 | "Content-Type": "application/x-www-form-urlencoded", 24 | Authorization: `Bearer ${session?.accessToken}`, 25 | }, 26 | body: new URLSearchParams({ 27 | grant_type: "urn:ietf:params:oauth:grant-type:uma-ticket", 28 | audience: NEXT_PUBLIC_OIDC_AUTHORIZATION_CLIENT_ID, 29 | response_mode: "decision", 30 | permission_resource_format: "uri", 31 | permission_resource_matching_uri: "true", 32 | // @ts-ignore 33 | permission: review["@id"].toString(), 34 | }), 35 | method: "POST", 36 | }); 37 | const permission: Permission = await response.json(); 38 | 39 | if (permission.result) { 40 | grant(true); 41 | } 42 | } catch (error) { 43 | console.error(error); 44 | grant(false); 45 | } 46 | })(); 47 | }, [review]); 48 | 49 | return isGranted; 50 | }; 51 | -------------------------------------------------------------------------------- /redocly.yaml: -------------------------------------------------------------------------------- 1 | # See https://redocly.com/docs/cli/configuration/rules/ for more information. 2 | extends: 3 | - recommended 4 | 5 | rules: 6 | info-license: off 7 | operation-4xx-response: off 8 | --------------------------------------------------------------------------------