├── .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 | : }
38 | >
39 | {docType === "hydra" ? "Hydra" : "OpenAPI"}
40 |
41 |
42 |
54 | >
55 | );
56 | };
57 |
58 | export default DocTypeMenuButton;
59 |
--------------------------------------------------------------------------------
/pwa/components/admin/layout/HydraLogo.tsx:
--------------------------------------------------------------------------------
1 | const HydraLogo = () => (
2 |
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 |
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 |
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 |
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 |
8 |
13 |
14 | {message}
15 |
16 |
25 |
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 |
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 |
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 |
--------------------------------------------------------------------------------