diff --git a/.docker-hub/varnish/Dockerfile b/.docker-hub/varnish/Dockerfile new file mode 100644 index 0000000000..e69fd49102 --- /dev/null +++ b/.docker-hub/varnish/Dockerfile @@ -0,0 +1,13 @@ +ARG VERSION=7.5.0 + +FROM varnish:${VERSION} + +USER root + +RUN set -e; \ + apt-get update; \ + apt-get -y install prometheus-varnish-exporter; + +RUN rm -rf /var/lib/apt/lists/*; + +USER varnish \ No newline at end of file diff --git a/.github/workflows/reusable-build-and-push.yml b/.github/workflows/reusable-build-and-push.yml index 6fffd8a6e3..2284c7a3cc 100644 --- a/.github/workflows/reusable-build-and-push.yml +++ b/.github/workflows/reusable-build-and-push.yml @@ -95,6 +95,18 @@ jobs: cache-from: type=gha,scope=print cache-to: type=gha,scope=print,mode=max + - name: Build and push varnish docker image + uses: docker/build-push-action@v5 + with: + push: true + file: .docker-hub/varnish/Dockerfile + tags: | + ${{ ((inputs.tag != '') && format('{0}/ecamp3-varnish:{1}', vars.DOCKER_HUB_USERNAME, inputs.tag) || '') }} + ${{ vars.DOCKER_HUB_USERNAME }}/ecamp3-varnish:${{ inputs.sha }} + context: . + cache-from: type=gha,scope=print + cache-to: type=gha,scope=print,mode=max + - name: Build and push db-backup-restore docker image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/reusable-dev-deployment.yml b/.github/workflows/reusable-dev-deployment.yml index ad8a221d8c..106829a6bf 100644 --- a/.github/workflows/reusable-dev-deployment.yml +++ b/.github/workflows/reusable-dev-deployment.yml @@ -93,6 +93,7 @@ jobs: --set ingress.basicAuth.enabled=${{ vars.BASIC_AUTH_ENABLED || false }} \ --set ingress.basicAuth.username=${{ secrets.BASIC_AUTH_USERNAME }} \ --set ingress.basicAuth.password='${{ secrets.BASIC_AUTH_PASSWORD }}' \ + --set apiCache.enabled=${{ vars.API_CACHE_ENABLED || false }} \ --set mail.dummyEnabled=true \ --set postgresql.url='${{ secrets.POSTGRES_URL }}/ecamp3${{ inputs.name }}?sslmode=require' \ --set postgresql.adminUrl='${{ secrets.POSTGRES_ADMIN_URL }}/ecamp3${{ inputs.name }}?sslmode=require' \ diff --git a/.github/workflows/reusable-e2e-tests-run.yml b/.github/workflows/reusable-e2e-tests-run.yml index 05184c9a3d..9f50b0b8a4 100644 --- a/.github/workflows/reusable-e2e-tests-run.yml +++ b/.github/workflows/reusable-e2e-tests-run.yml @@ -49,7 +49,7 @@ jobs: docker-compose- # start necessary containers - - run: docker compose up -d php caddy frontend pdf print browserless database docker-host + - run: docker compose up -d php caddy frontend pdf print browserless database docker-host http-cache mail - uses: cypress-io/github-action@v5 with: diff --git a/.github/workflows/reusable-stage-prod-deployment.yml b/.github/workflows/reusable-stage-prod-deployment.yml index 268da9aef2..0e64c19a5d 100644 --- a/.github/workflows/reusable-stage-prod-deployment.yml +++ b/.github/workflows/reusable-stage-prod-deployment.yml @@ -52,6 +52,7 @@ jobs: --set ingress.basicAuth.enabled=${{ vars.BASIC_AUTH_ENABLED || false }} \ --set ingress.basicAuth.username=${{ secrets.BASIC_AUTH_USERNAME }} \ --set ingress.basicAuth.password='${{ secrets.BASIC_AUTH_PASSWORD }}' \ + --set apiCache.enabled=${{ vars.API_CACHE_ENABLED || false }} \ --set mail.dsn=${{ secrets.MAILER_DSN }} \ --set postgresql.url='${{ secrets.POSTGRES_URL }}/${{ secrets.DB_NAME }}?sslmode=require' \ --set postgresql.dropDBOnUninstall=false \ diff --git a/.helm/.env-example b/.helm/.env-example index 0d8077eee5..31e831ba5b 100644 --- a/.helm/.env-example +++ b/.helm/.env-example @@ -5,6 +5,8 @@ domain=ecamp3.ch POSTGRES_URL= POSTGRES_ADMIN_URL= +API_CACHE_ENABLED=false + BASIC_AUTH_ENABLED=false BASIC_AUTH_USERNAME=test BASIC_AUTH_PASSWORD=test diff --git a/.helm/build-images.sh b/.helm/build-images.sh index 6ff76fb40b..9ba604edd0 100755 --- a/.helm/build-images.sh +++ b/.helm/build-images.sh @@ -39,6 +39,10 @@ print_image_tag="${docker_hub_account}/ecamp3-print:${version}" docker build "$REPO_DIR" -f "$REPO_DIR"/.docker-hub/print/Dockerfile $print_sentry_build_args -t "$print_image_tag" docker push "$print_image_tag" +varnish_image_tag="${docker_hub_account}/ecamp3-varnish:${version}" +docker build "$REPO_DIR" -f "$REPO_DIR"/.docker-hub/varnish/Dockerfile -t "$varnish_image_tag" +docker push "$varnish_image_tag" + export REPO_OWNER=${docker_hub_account} export VERSION=${version} db_backup_restore_docker_compose_path="$REPO_DIR"/.helm/ecamp3/files/db-backup-restore-image/docker-compose.yml diff --git a/.helm/deploy-to-cluster.sh b/.helm/deploy-to-cluster.sh index 9e2683457e..241d202e58 100755 --- a/.helm/deploy-to-cluster.sh +++ b/.helm/deploy-to-cluster.sh @@ -42,6 +42,7 @@ for i in 1; do values="$values --set ingress.basicAuth.enabled=$BASIC_AUTH_ENABLED" values="$values --set ingress.basicAuth.username=$BASIC_AUTH_USERNAME" values="$values --set ingress.basicAuth.password=$BASIC_AUTH_PASSWORD" + values="$values --set apiCache.enabled=$API_CACHE_ENABLED" values="$values --set postgresql.enabled=false" values="$values --set postgresql.url=$POSTGRES_URL/ecamp3$instance_name-"$i"?sslmode=require" values="$values --set postgresql.adminUrl=$POSTGRES_ADMIN_URL/ecamp3$instance_name-"$i"?sslmode=require" @@ -98,6 +99,8 @@ for i in 1; do values="$values --set $imagespec.image.repository=docker.io/${docker_hub_account}/ecamp3-api-$imagespec" done + values="$values --set apiCache.image.repository=docker.io/${docker_hub_account}/ecamp3-varnish" + values="$values --set postgresql.dbBackupRestoreImage.pullPolicy=$pull_policy" values="$values --set postgresql.dbBackupRestoreImage.repository=docker.io/${docker_hub_account}/ecamp3-db-backup-restore" diff --git a/.helm/ecamp3/files/vcl b/.helm/ecamp3/files/vcl new file mode 120000 index 0000000000..e036f3976d --- /dev/null +++ b/.helm/ecamp3/files/vcl @@ -0,0 +1 @@ +../../../api/docker/varnish/vcl \ No newline at end of file diff --git a/.helm/ecamp3/templates/_helpers.tpl b/.helm/ecamp3/templates/_helpers.tpl index c61fbfd962..ecb73edadc 100644 --- a/.helm/ecamp3/templates/_helpers.tpl +++ b/.helm/ecamp3/templates/_helpers.tpl @@ -82,6 +82,19 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this {{- end }} {{- end }} +{{/* +Name for all HTTP cache-related resources. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "apiCache.name" -}} +{{- $name := default .Chart.Name .Values.chartNameOverride }} +{{- if contains $name (include "app.name" .) }} +{{- printf "%s-api-cache" (include "app.name" .) | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s-api-cache" (include "app.name" .) $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + {{/* Name for all db_backup_job releated resources. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). @@ -227,6 +240,14 @@ app.kubernetes.io/name: {{ include "chart.name" . }}-browserless {{ include "app.commonSelectorLabels" . }} {{- end }} +{{/* +Selector labels for HTTP Cache +*/}} +{{- define "apiCache.selectorLabels" -}} +app.kubernetes.io/name: {{ include "chart.name" . }}-api-cache +{{ include "app.commonSelectorLabels" . }} +{{- end }} + {{/* Selector labels for db-backup-job */}} diff --git a/.helm/ecamp3/templates/api_cache_deployment.yaml b/.helm/ecamp3/templates/api_cache_deployment.yaml new file mode 100644 index 0000000000..4fc2a179b2 --- /dev/null +++ b/.helm/ecamp3/templates/api_cache_deployment.yaml @@ -0,0 +1,116 @@ +{{- if .Values.apiCache.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "apiCache.name" . }} + labels: + {{- include "apiCache.selectorLabels" . | nindent 4 }} + {{- include "app.commonLabels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "apiCache.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "apiCache.selectorLabels" . | nindent 8 }} + annotations: + checksum/vclConfigmap: {{ include (print $.Template.BasePath "/api_cache_vcl_configmap.yaml") . | sha256sum }} + rollme: {{ .Values.imageTag | quote }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + enableServiceLinks: false + containers: + - name: {{ .Chart.Name }}-api-cache-varnishd + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.apiCache.image.repository }}:{{ .Values.apiCache.image.tag | default .Values.imageTag }}" + imagePullPolicy: {{ .Values.apiCache.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.apiCache.varnishHttpPort }} + protocol: TCP + - name: purge + containerPort: {{ .Values.apiCache.varnishPurgePort }} + protocol: TCP + env: + - name: VARNISH_SIZE + value: "{{ .Values.apiCache.varnishSize }}" + - name: VARNISH_HTTP_PORT + value: "{{ .Values.apiCache.varnishHttpPort }}" + - name: COOKIE_PREFIX + value: {{ include "api.cookiePrefix" . | quote }} + args: + - -a + - {{ printf ":%d,HTTP" (.Values.apiCache.varnishPurgePort | int) }} + - -p + - http_max_hdr=96 + resources: + {{- toYaml .Values.apiCache.resources | nindent 12 }} + volumeMounts: + - name: vcl-configmap + mountPath: /etc/varnish + - name: vsm + mountPath: /var/lib/varnish + {{- if .Values.apiCache.logging.enabled }} + - name: {{ .Chart.Name }}-api-cache-varnishncsa + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.apiCache.image.repository }}:{{ .Values.apiCache.image.tag | default .Values.imageTag }}" + imagePullPolicy: {{ .Values.apiCache.image.pullPolicy }} + command: + - varnishncsa + - -b + - -c + {{- if .Values.apiCache.logging.customOutputJsonFormat }} + - -j + {{- end }} + {{- if .Values.apiCache.logging.customOutput }} + - -F + - {{ .Values.apiCache.logging.customOutput | squote }} + {{- end }} + - -t + - {{ .Values.apiCache.logging.timeout | quote }} + resources: + {{- toYaml .Values.apiCache.logging.resources | nindent 12 }} + volumeMounts: + - name: vsm + mountPath: /var/lib/varnish + {{- end }} + {{- if .Values.apiCache.prometheus.enabled }} + - name: {{ .Chart.Name }}-api-cache-prometheus-exporter + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.apiCache.image.repository }}:{{ .Values.apiCache.image.tag | default .Values.imageTag }}" + imagePullPolicy: {{ .Values.apiCache.image.pullPolicy }} + ports: + - name: metrics + containerPort: {{ .Values.apiCache.prometheus.port }} + protocol: TCP + resources: + {{- toYaml .Values.apiCache.prometheus.resources | nindent 12 }} + command: + - prometheus-varnish-exporter + - -web.telemetry-path + - "{{ .Values.apiCache.prometheus.path }}" + - -web.listen-address + - ":{{ .Values.apiCache.prometheus.port }}" + volumeMounts: + - name: vsm + mountPath: /var/lib/varnish + {{- end }} + volumes: + - name: vcl-configmap + configMap: + name: {{ include "apiCache.name" . }}-vcl-configmap + - name: vsm + emptyDir: + medium: Memory +{{- end }} \ No newline at end of file diff --git a/.helm/ecamp3/templates/api_cache_service.yaml b/.helm/ecamp3/templates/api_cache_service.yaml new file mode 100644 index 0000000000..fa9d92b0e5 --- /dev/null +++ b/.helm/ecamp3/templates/api_cache_service.yaml @@ -0,0 +1,26 @@ +{{- if .Values.apiCache.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "apiCache.name" . }} + labels: + {{- include "apiCache.selectorLabels" . | nindent 4 }} + {{- include "app.commonLabels" . | nindent 4 }} +spec: + type: {{ .Values.apiCache.service.type }} + ports: + - port: {{ .Values.apiCache.service.ports.http }} + targetPort: http + protocol: TCP + name: http + - port: {{ .Values.apiCache.service.ports.purge }} + targetPort: purge + protocol: TCP + name: purge + - port: {{ .Values.apiCache.prometheus.port }} + targetPort: metrics + protocol: TCP + name: metrics + selector: + {{- include "apiCache.selectorLabels" . | nindent 4 }} +{{- end }} \ No newline at end of file diff --git a/.helm/ecamp3/templates/api_cache_vcl_configmap.yaml b/.helm/ecamp3/templates/api_cache_vcl_configmap.yaml new file mode 100644 index 0000000000..17c6c64078 --- /dev/null +++ b/.helm/ecamp3/templates/api_cache_vcl_configmap.yaml @@ -0,0 +1,18 @@ +{{- if .Values.apiCache.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "apiCache.name" . }}-vcl-configmap + labels: + {{- include "apiCache.selectorLabels" . | nindent 4 }} + {{- include "app.commonLabels" . | nindent 4 }} +data: +# includes all files except the ones starting with _ +{{ (.Files.Glob "files/vcl/[!_]*").AsConfig | indent 2 }} + # override backend config + _config.vcl: |- + backend default { + .host = "{{ include "api.name" .}}"; + .port = "{{ .Values.api.service.port }}"; + } +{{- end }} \ No newline at end of file diff --git a/.helm/ecamp3/templates/api_configmap.yaml b/.helm/ecamp3/templates/api_configmap.yaml index a719502da9..ef96a5d5c3 100644 --- a/.helm/ecamp3/templates/api_configmap.yaml +++ b/.helm/ecamp3/templates/api_configmap.yaml @@ -22,3 +22,9 @@ data: SENTRY_API_DSN: {{ "" | quote }} {{- end }} FRONTEND_BASE_URL: {{ include "frontend.url" . | quote }} + API_CACHE_ENABLED: {{ .Values.apiCache.enabled | quote }} + {{- if .Values.apiCache.enabled }} + VARNISH_API_URL: {{ printf "%s:%d" (include "apiCache.name" .) (.Values.apiCache.service.ports.purge | int) | quote }} + {{- else }} + VARNISH_API_URL: {{ "" | quote }} + {{- end}} diff --git a/.helm/ecamp3/templates/api_ingress.yaml b/.helm/ecamp3/templates/api_ingress.yaml index b104b3f3d1..f3b98141a9 100644 --- a/.helm/ecamp3/templates/api_ingress.yaml +++ b/.helm/ecamp3/templates/api_ingress.yaml @@ -29,7 +29,13 @@ spec: pathType: Prefix backend: service: + {{- if .Values.apiCache.enabled }} + name: {{ include "apiCache.name" . }} + port: + number: {{ .Values.apiCache.service.ports.http }} + {{- else }} name: {{ include "api.name" . }} port: number: {{ .Values.api.service.port }} + {{- end }} {{- end }} diff --git a/.helm/ecamp3/templates/print_ingress.yaml b/.helm/ecamp3/templates/print_ingress.yaml index bbe663161d..334c283fd7 100644 --- a/.helm/ecamp3/templates/print_ingress.yaml +++ b/.helm/ecamp3/templates/print_ingress.yaml @@ -12,6 +12,7 @@ metadata: {{- end }} {{- include "ingress.basicAuth.annotations" . | nindent 4 }} {{- if not (.Values.print.ingress.readTimeoutSeconds | empty) }} + nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/proxy-read-timeout: {{ .Values.print.ingress.readTimeoutSeconds | quote }} {{- end }} spec: diff --git a/.helm/ecamp3/values.yaml b/.helm/ecamp3/values.yaml index 07d2650866..72031c27d8 100644 --- a/.helm/ecamp3/values.yaml +++ b/.helm/ecamp3/values.yaml @@ -222,7 +222,48 @@ ingress: className: nginx tls: - +apiCache: + enabled: false + image: + repository: "docker.io/ecamp/ecamp3-varnish" + pullPolicy: IfNotPresent + # Overrides the image tag whose shared default is .Values.imageTag + tag: + service: + type: ClusterIP + ports: + http: 3000 + purge: 3001 + varnishSize: 50M + varnishHttpPort: 8080 + varnishPurgePort: 8081 + resources: + requests: + cpu: 10m + memory: 100Mi + logging: + enabled: true + customOutput: '{ "received_at": "%t", "varnish_side": "%{Varnish:side}x", "method": "%m", "url": "%U", "query": "%q", "response_bytes": %b, "time_taken": %D, "status": %s, "handling": "%{Varnish:handling}x", "response_reason": "%{VSL:RespReason}x", "fetch_error": "%{VSL:FetchError}x" }' + customOutputJsonFormat: true + # Timeout before returning error on initial VSM connection. + # If set the VSM connection is retried every 0.5 seconds for this many seconds. + # If zero the connection is attempted only once and will fail immediately if unsuccessful. + # If set to "off", the connection will not fail, allowing the utility to start and wait indefinetely for the Varnish instance to appear. + # Defaults to "off" in this case. + timeout: "off" + resources: + requests: + cpu: 10m + memory: 20Mi + prometheus: + enabled: true + path: "/metrics" + port: 9131 + resources: + requests: + cpu: 10m + memory: 20Mi + autoscaling: enabled: false minReplicas: 1 diff --git a/.ops/observability/prometheus-values-dev.yml b/.ops/observability/prometheus-values-dev.yml index e20b3f6a45..892f22954a 100644 --- a/.ops/observability/prometheus-values-dev.yml +++ b/.ops/observability/prometheus-values-dev.yml @@ -31,6 +31,16 @@ prometheus: - default endpoints: - port: "api-metrics" + - name: "varnish" + selector: + matchLabels: + app.kubernetes.io/instance: ecamp3-dev + app.kubernetes.io/name: ecamp3-api-cache + namespaceSelector: + matchNames: + - default + endpoints: + - port: "api-cache-metrics" prometheusSpec: storageSpec: volumeClaimTemplate: diff --git a/api/.env b/api/.env index 112327d4d3..c09c3d6eb7 100644 --- a/api/.env +++ b/api/.env @@ -17,6 +17,8 @@ TRUSTED_PROXIES=::1,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ADDITIONAL_TRUSTED_HOSTS=localhost COOKIE_PREFIX=localhost_ +VARNISH_API_URL=http://http-cache:8081 +API_CACHE_ENABLED=true ###> symfony/framework-bundle ### APP_ENV=dev diff --git a/api/composer.json b/api/composer.json index c9f96da9ca..48224f1715 100644 --- a/api/composer.json +++ b/api/composer.json @@ -12,6 +12,8 @@ "doctrine/doctrine-migrations-bundle": "3.3.1", "doctrine/orm": "2.19.5", "exercise/htmlpurifier-bundle": "5.0", + "friendsofsymfony/http-cache": "3.0.0", + "friendsofsymfony/http-cache-bundle": "3.0.0", "google/recaptcha": "1.3.0", "guzzlehttp/guzzle": "7.8.1", "knpuniversity/oauth2-client-bundle": "2.18.1", @@ -31,6 +33,7 @@ "symfony/expression-language": "7.0.7", "symfony/flex": "2.4.5", "symfony/framework-bundle": "7.0.7", + "symfony/http-client": "7.0.7", "symfony/intl": "7.0.7", "symfony/mailer": "7.0.7", "symfony/monolog-bundle": "3.10.0", @@ -52,6 +55,7 @@ "hautelook/alice-bundle": "2.13.0", "justinrainbow/json-schema": "5.2.13", "php-coveralls/php-coveralls": "2.7.0", + "phpspec/prophecy-phpunit": "2.2", "phpstan/phpstan": "1.11.2", "phpunit/phpunit": "10.5.20", "rector/rector": "1.1.0", @@ -59,7 +63,6 @@ "symfony/browser-kit": "7.0.7", "symfony/css-selector": "7.0.7", "symfony/debug-bundle": "7.0.7", - "symfony/http-client": "7.0.7", "symfony/maker-bundle": "1.59.1", "symfony/phpunit-bridge": "7.0.7", "symfony/stopwatch": "7.0.7", diff --git a/api/composer.lock b/api/composer.lock index 8fda85bfcc..e598611344 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4ef5b0a7cf55c1d6fcce352f479c1bac", + "content-hash": "9ab7073e759ce8d5447f78bf3417d804", "packages": [ { "name": "api-platform/core", @@ -2006,6 +2006,188 @@ }, "time": "2023-11-17T15:01:25+00:00" }, + { + "name": "friendsofsymfony/http-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfSymfony/FOSHttpCache.git", + "reference": "2b2ccae740c164c55ea43c6ccf5fca3011d00537" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfSymfony/FOSHttpCache/zipball/2b2ccae740c164c55ea43c6ccf5fca3011d00537", + "reference": "2b2ccae740c164c55ea43c6ccf5fca3011d00537", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-http/async-client-implementation": "^1.1.0 || ^2.0", + "php-http/client-common": "^1.1.0 || ^2.0", + "php-http/discovery": "^1.12", + "psr/http-client-implementation": "^1.0 || ^2.0", + "psr/http-factory": "^1.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/options-resolver": "^6.4 || ^7.0" + }, + "conflict": { + "toflar/psr6-symfony-http-cache-store": "<2.2.1" + }, + "require-dev": { + "mockery/mockery": "^1.6.0", + "monolog/monolog": "^1.0", + "php-http/guzzle7-adapter": "^1", + "php-http/mock-client": "^1.6.0", + "phpunit/phpunit": "^10.5", + "symfony/http-kernel": "^6.4|| ^7.0", + "symfony/process": "^6.4|| ^7.0" + }, + "suggest": { + "friendsofsymfony/http-cache-bundle": "For integration with the Symfony framework", + "monolog/monolog": "For logging issues while invalidating", + "phpunit/phpunit": "To build tests with the WebServerSubscriber, ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "FOS\\HttpCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Liip AG", + "homepage": "http://www.liip.ch/" + }, + { + "name": "Driebit", + "email": "tech@driebit.nl", + "homepage": "http://www.driebit.nl" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/friendsofsymfony/FOSHttpCache/contributors" + } + ], + "description": "Tools to manage HTTP caching proxies with PHP", + "homepage": "https://github.com/friendsofsymfony/FOSHttpCache", + "keywords": [ + "caching", + "http", + "invalidation", + "nginx", + "purge", + "varnish" + ], + "support": { + "issues": "https://github.com/FriendsOfSymfony/FOSHttpCache/issues", + "source": "https://github.com/FriendsOfSymfony/FOSHttpCache/tree/3.0.0" + }, + "time": "2024-05-04T18:09:55+00:00" + }, + { + "name": "friendsofsymfony/http-cache-bundle", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfSymfony/FOSHttpCacheBundle.git", + "reference": "d9606c656c0bc4d81ca8130c7fb3fca572dce93c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfSymfony/FOSHttpCacheBundle/zipball/d9606c656c0bc4d81ca8130c7fb3fca572dce93c", + "reference": "d9606c656c0bc4d81ca8130c7fb3fca572dce93c", + "shasum": "" + }, + "require": { + "friendsofsymfony/http-cache": "^2.15 || ^3.0", + "php": "^8.1", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0" + }, + "conflict": { + "symfony/monolog-bridge": "<3.4.4", + "twig/twig": "<1.12.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.54", + "guzzlehttp/guzzle": "^7.2", + "jean-beru/fos-http-cache-cloudfront": "^1.1", + "matthiasnoback/symfony-config-test": "^4.3.0 || ^5.1", + "matthiasnoback/symfony-dependency-injection-test": "^4.3.1 || ^5.0", + "mockery/mockery": "^1.6.9", + "monolog/monolog": "*", + "php-http/discovery": "^1.13", + "php-http/guzzle7-adapter": "^0.1.1", + "php-http/httplug": "^2.2.0", + "php-http/message": "^1.0 || ^2.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^10.5", + "symfony/browser-kit": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/css-selector": "^6.4 || ^7.0", + "symfony/finder": "^6.4 || ^7.0", + "symfony/monolog-bundle": "^3.0", + "symfony/routing": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^v3.8" + }, + "suggest": { + "jean-beru/fos-http-cache-cloudfront": "To use CloudFront proxy", + "sensio/framework-extra-bundle": "For Tagged Cache Invalidation", + "symfony/console": "To send invalidation requests from the command line", + "symfony/expression-language": "For Tagged Cache Invalidation" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "FOS\\HttpCacheBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Liip AG", + "homepage": "http://www.liip.ch/" + }, + { + "name": "Driebit", + "email": "tech@driebit.nl", + "homepage": "http://www.driebit.nl" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/friendsofsymfony/FOSHttpCacheBundle/contributors" + } + ], + "description": "Set path based HTTP cache headers and send invalidation requests to your HTTP cache", + "homepage": "https://github.com/FriendsOfSymfony/FOSHttpCacheBundle", + "keywords": [ + "caching", + "esi", + "http", + "invalidation", + "purge", + "varnish" + ], + "support": { + "issues": "https://github.com/FriendsOfSymfony/FOSHttpCacheBundle/issues", + "source": "https://github.com/FriendsOfSymfony/FOSHttpCacheBundle/tree/3.0.0" + }, + "time": "2024-05-04T18:15:18+00:00" + }, { "name": "gedmo/doctrine-extensions", "version": "v3.15.0", @@ -11128,12 +11310,12 @@ "version": "v5.2.13", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", + "url": "https://github.com/jsonrainbow/json-schema.git", "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", "shasum": "" }, @@ -11722,6 +11904,127 @@ }, "time": "2023-11-22T10:21:01+00:00" }, + { + "name": "phpspec/prophecy", + "version": "v1.19.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "67a759e7d8746d501c41536ba40cd9c0a07d6a87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/67a759e7d8746d501c41536ba40cd9c0a07d6a87", + "reference": "67a759e7d8746d501c41536ba40cd9c0a07d6a87", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.2 || ^2.0", + "php": "^7.2 || 8.0.* || 8.1.* || 8.2.* || 8.3.*", + "phpdocumentor/reflection-docblock": "^5.2", + "sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0", + "sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0 || ^6.0" + }, + "require-dev": { + "phpspec/phpspec": "^6.0 || ^7.0", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "dev", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/v1.19.0" + }, + "time": "2024-02-29T11:52:51+00:00" + }, + { + "name": "phpspec/prophecy-phpunit", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy-phpunit.git", + "reference": "16e1247e139434bce0bac09848bc5c8d882940fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy-phpunit/zipball/16e1247e139434bce0bac09848bc5c8d882940fc", + "reference": "16e1247e139434bce0bac09848bc5c8d882940fc", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8", + "phpspec/prophecy": "^1.18", + "phpunit/phpunit": "^9.1 || ^10.1 || ^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\PhpUnit\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + } + ], + "description": "Integrating the Prophecy mocking library in PHPUnit test cases", + "homepage": "http://phpspec.net", + "keywords": [ + "phpunit", + "prophecy" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy-phpunit/issues", + "source": "https://github.com/phpspec/prophecy-phpunit/tree/v2.2.0" + }, + "time": "2024-03-01T08:33:58+00:00" + }, { "name": "phpstan/phpstan", "version": "1.11.2", diff --git a/api/config/bundles.php b/api/config/bundles.php index 30b23fc306..204c2b254d 100644 --- a/api/config/bundles.php +++ b/api/config/bundles.php @@ -5,6 +5,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Exercise\HTMLPurifierBundle\ExerciseHTMLPurifierBundle; use Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle; +use FOS\HttpCacheBundle\FOSHttpCacheBundle; use Hautelook\AliceBundle\HautelookAliceBundle; use KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; @@ -42,4 +43,5 @@ KnpUOAuth2ClientBundle::class => ['all' => true], SentryBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true], + FOSHttpCacheBundle::class => ['all' => true], ]; diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 044e5c8290..c63e72f3e8 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -29,8 +29,6 @@ api_platform: versions: [3] defaults: stateless: true - cache_headers: - vary: ['Content-Type', 'Authorization', 'Origin'] pagination_enabled: false itemOperations: [ 'get', 'patch', 'delete' ] collection_operations: diff --git a/api/config/packages/http_cache.yaml b/api/config/packages/http_cache.yaml new file mode 100644 index 0000000000..63f8ee24fe --- /dev/null +++ b/api/config/packages/http_cache.yaml @@ -0,0 +1,35 @@ +parameters: + app.httpCache.matchPath: '^/(content_types|camps/[0-9a-f]*/categories)' + +fos_http_cache: + debug: + enabled: true # this sets the X-Cache-Debug response header; can be removed later-on + tags: + enabled: true + response_header: xkey + max_header_value_length: 4096 + separator: ' ' + cache_manager: + custom_proxy_client: App\HttpCache\VarnishProxyClient + cache_control: + defaults: + overwrite: true + rules: + # matches /content_types endpoint + # matches /camps/133/categories endpoint + - + match: + path: '%app.httpCache.matchPath%' + headers: + overwrite: true + cache_control: { public: true, max_age: 0, s_maxage: 3600 } + vary: [Accept, Content-Type, Authorization, Origin] + + # match everything else to set defaults + - + match: + path: ^/ + headers: + overwrite: true + cache_control: { no-cache: true, private: true } + vary: [Accept, Content-Type, Authorization, Origin] \ No newline at end of file diff --git a/api/config/packages/test/http_cache.yaml b/api/config/packages/test/http_cache.yaml new file mode 100644 index 0000000000..419340180e --- /dev/null +++ b/api/config/packages/test/http_cache.yaml @@ -0,0 +1,5 @@ +fos_http_cache: + proxy_client: + noop: ~ + cache_manager: + custom_proxy_client: fos_http_cache.proxy_client.noop diff --git a/api/config/services.yaml b/api/config/services.yaml index e61e1a7c99..c82a494f16 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -4,6 +4,7 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.httpCache.maxHeaderSize: 31500 # lower than Vanish default http_resp_size (32k) services: # default configuration for services in *this* file @@ -147,6 +148,45 @@ services: tags: - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_created, method: onJWTCreated } + App\HttpCache\PurgeHttpCacheListener: + arguments: + - '@api_platform.iri_converter' + - '@api_platform.resource_class_resolver' + - '@api_platform.property_accessor' + tags: + - { name: doctrine.event_listener, event: preUpdate } + - { name: doctrine.event_listener, event: onFlush } + - { name: doctrine.event_listener, event: postFlush } + + api_platform.http_cache.tag_collector: + class: App\HttpCache\TagCollector + + api_platform.http_cache.listener.response.configure: + class: ApiPlatform\HttpCache\EventListener\AddHeadersListener + tags: + - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse, priority: 11 } + + App\HttpCache\AddCollectionTagsListener: + tags: + - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse, priority: 12 } + + App\HttpCache\CacheControlListener: + arguments: + $apiCacheEnabled: '%env(API_CACHE_ENABLED)%' + $maxHeaderSize: '%app.httpCache.maxHeaderSize%' + $headerKey: '%fos_http_cache.tag_handler.response_header%' + tags: + - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse, priority: -1 } # priority -1, executed after FOSHttpCacheBundle Listeners + + App\HttpCache\VarnishProxyClient: + arguments: + $apiCacheEnabled: '%env(API_CACHE_ENABLED)%' + $varnishApiUrl: '%env(VARNISH_API_URL)%' + + App\HttpCache\ResponseTagger: + arguments: + $matchPath: '%app.httpCache.matchPath%' + # Entity Filter App\Doctrine\FilterByCurrentUserExtension: tags: diff --git a/api/docker/varnish/vcl/_config.vcl b/api/docker/varnish/vcl/_config.vcl new file mode 100644 index 0000000000..1f81b8bdab --- /dev/null +++ b/api/docker/varnish/vcl/_config.vcl @@ -0,0 +1,4 @@ +backend default { + .host = "caddy"; + .port = "3000"; +} \ No newline at end of file diff --git a/api/docker/varnish/vcl/default.vcl b/api/docker/varnish/vcl/default.vcl new file mode 100644 index 0000000000..15df5b7472 --- /dev/null +++ b/api/docker/varnish/vcl/default.vcl @@ -0,0 +1,103 @@ +vcl 4.0; + +import std; +import xkey; +import cookie; +import var; + +include "./_config.vcl"; +include "./fos_tags_xkey.vcl"; +include "./fos_debug.vcl"; + +sub vcl_recv { + + var.set("originalUrl", req.http.X-Forwarded-Prefix + req.url); + + if(var.get("originalUrl") ~ "^/api/varnish/healthcheck") { + return(synth(200,"OK")); + } + + # Support xkey purge requests + # see https://raw.githubusercontent.com/varnish/varnish-modules/master/src/vmod_xkey.vcc + call fos_tags_xkey_recv; + + # exclude other services (frontend, print, etc.) + if (var.get("originalUrl") !~ "^/api") { + return(pass); + } + + # exclude API documentation, profiler and graphql endpoint + if (var.get("originalUrl") ~ "^/api/docs" + || var.get("originalUrl") ~ "^/api/graphql" + || var.get("originalUrl") ~ "^/api/bundles" + || var.get("originalUrl") ~ "^/api/contexts" + || var.get("originalUrl") ~ "^/api/_profiler" + || var.get("originalUrl") ~ "^/api/_wdt") { + return(pass); + } + + # exclude any format other than HAL + if (req.url !~ "\.jsonhal$" && req.http.Accept !~ "application/hal\+json"){ + return(pass); + } + + # exclude any request with query parameters, until cache handling of query params is properly implemented + if (req.url ~ "\?"){ + return(pass); + } + + # Extract JWT cookie for later use in vcl_hash + # Failsafe: Pass cache if JWT cookie is not set (also for example, if COOKIE_PREFIX is not properly configured) + if (req.http.Cookie) { + cookie.parse(req.http.Cookie); + cookie.keep(std.getenv("COOKIE_PREFIX") + "jwt_hp," + std.getenv("COOKIE_PREFIX") + "jwt_s"); + + if(cookie.get_string() == ""){ + return(pass); + } + + var.set("JWT", cookie.get_string()); + } +} + +sub vcl_hash { + # Include JWT cookies in cache hash + hash_data(var.get("JWT")); + + # using URL (=path), but not using Host/ServerIP; this allows to share cache between print & normal API calls + hash_data(req.url); + + return(lookup); +} + +sub vcl_req_cookie { + # Varnish by default disables caching whenever the request header "Cookie" is set in the request (default safe behavior) + # this bypasses the default behaviour; this is safe because we included "Cookie" in the "Vary" header + return (hash); +} + +sub vcl_backend_response { + if (bereq.uncacheable) { + return (deliver); + } + call vcl_beresp_stale; + + # Varnish by default disables caching whenever the reponse header "Set-Cookie" is set in the request (default safe behavior) + # commenting the following line bypasses the default behaviour + # call vcl_beresp_cookie; + + call vcl_beresp_control; + call vcl_beresp_vary; + return (deliver); +} + +sub vcl_deliver { + call fos_tags_xkey_deliver; + call fos_debug_deliver; + + # reset cache control header to avoid caching by any other upstream proxies + if (resp.http.Content-Type ~ "application/hal\+json"){ + set resp.http.Cache-Control = "no-cache, private"; + } +} + diff --git a/api/docker/varnish/vcl/fos_debug.vcl b/api/docker/varnish/vcl/fos_debug.vcl new file mode 100644 index 0000000000..1b3adebe30 --- /dev/null +++ b/api/docker/varnish/vcl/fos_debug.vcl @@ -0,0 +1,21 @@ +/* + * This file is part of the FOSHttpCache package. + * + * (c) FriendsOfSymfony + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +sub fos_debug_deliver { + # Add X-Cache header if debugging is enabled + if (resp.http.X-Cache-Debug) { + if (obj.hits > 0) { + set resp.http.X-Cache = "HIT"; + } else if (obj.uncacheable) { + set resp.http.X-Cache = "PASS"; + } else { + set resp.http.X-Cache = "MISS"; + } + } +} diff --git a/api/docker/varnish/vcl/fos_tags_xkey.vcl b/api/docker/varnish/vcl/fos_tags_xkey.vcl new file mode 100644 index 0000000000..ea2e468d1e --- /dev/null +++ b/api/docker/varnish/vcl/fos_tags_xkey.vcl @@ -0,0 +1,45 @@ +/* + * This file is part of the FOSHttpCache package. + * + * (c) FriendsOfSymfony + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import xkey; + +sub fos_tags_xkey_recv { + if (req.method == "PURGEKEYS") { + + # only allow purge requests on port 8081 + if (std.port(server.ip) != 8081) { + return (synth(405, "Not allowed")); + } + + # If neither of the headers are provided we return 400 to simplify detecting wrong configuration + if (!req.http.xkey-purge && !req.http.xkey-softpurge) { + return (synth(400, "Neither header XKey-Purge or XKey-SoftPurge set")); + } + + # Based on provided header invalidate (purge) and/or expire (softpurge) the tagged content + set req.http.n-gone = 0; + set req.http.n-softgone = 0; + if (req.http.xkey-purge) { + set req.http.n-gone = xkey.purge(req.http.xkey-purge); + } + + if (req.http.xkey-softpurge) { + set req.http.n-softgone = xkey.softpurge(req.http.xkey-softpurge); + } + + return (synth(200, "Purged "+req.http.n-gone+" objects, expired "+req.http.n-softgone+" objects")); + } +} + +sub fos_tags_xkey_deliver { + if (!resp.http.X-Cache-Debug) { + // Remove tag headers when delivering to non debug client + unset resp.http.xkey; + } +} diff --git a/api/src/Entity/BaseEntity.php b/api/src/Entity/BaseEntity.php index 4d601bd499..58f4a8af42 100644 --- a/api/src/Entity/BaseEntity.php +++ b/api/src/Entity/BaseEntity.php @@ -11,7 +11,7 @@ #[ORM\MappedSuperclass] #[ORM\Index(columns: ['createTime'])] #[ORM\Index(columns: ['updateTime'])] -abstract class BaseEntity { +abstract class BaseEntity implements HasId { /** * An internal, unique, randomly generated identifier of this entity. */ diff --git a/api/src/Entity/Camp.php b/api/src/Entity/Camp.php index 272b5822c0..eea7559c5e 100644 --- a/api/src/Entity/Camp.php +++ b/api/src/Entity/Camp.php @@ -101,7 +101,11 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy /** * Types of programme, such as sports activities or meal times. */ - #[ApiProperty(writable: false, example: '["/categories/1a2b3c4d"]')] + #[ApiProperty( + writable: false, + uriTemplate: Category::CAMP_SUBRESOURCE_URI_TEMPLATE, + example: '"/camp/1a2b3c4d/categories"' + )] #[Groups(['read'])] #[ORM\OneToMany(targetEntity: Category::class, mappedBy: 'camp', orphanRemoval: true, cascade: ['persist'])] public Collection $categories; diff --git a/api/src/Entity/Category.php b/api/src/Entity/Category.php index 6889d971aa..6d274dc3a0 100644 --- a/api/src/Entity/Category.php +++ b/api/src/Entity/Category.php @@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\InputFilter; @@ -54,6 +55,17 @@ normalizationContext: self::ITEM_NORMALIZATION_CONTEXT, securityPostDenormalize: 'is_granted("CAMP_MEMBER", object) or is_granted("CAMP_MANAGER", object)' ), + new GetCollection( + name: 'BelongsToCamp_App\Entity\Category_get_collection', + uriTemplate: self::CAMP_SUBRESOURCE_URI_TEMPLATE, + uriVariables: [ + 'campId' => new Link( + fromClass: Camp::class, + toProperty: 'camp', + security: 'is_granted("CAMP_COLLABORATOR", camp) or is_granted("CAMP_IS_PROTOTYPE", camp)' + ), + ], + ), ], denormalizationContext: ['groups' => ['write']], normalizationContext: ['groups' => ['read']], @@ -65,6 +77,8 @@ class Category extends BaseEntity implements BelongsToCampInterface, CopyFromPro use ClassInfoTrait; use HasRootContentNodeTrait; + public const CAMP_SUBRESOURCE_URI_TEMPLATE = '/camps/{campId}/categories.{_format}'; + public const ITEM_NORMALIZATION_CONTEXT = [ 'groups' => [ 'read', diff --git a/api/src/Entity/HasId.php b/api/src/Entity/HasId.php new file mode 100644 index 0000000000..7bbf61c545 --- /dev/null +++ b/api/src/Entity/HasId.php @@ -0,0 +1,7 @@ +resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + public function onKernelResponse(ResponseEvent $event): void { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if ( + (!$attributes = RequestAttributesExtractor::extractAttributes($request)) + || $request->attributes->get('_api_platform_disable_listeners') + ) { + return; + } + + if ($operation instanceof CollectionOperationInterface) { + // Allows to purge collections + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $attributes['resource_class']); + $iri = $this->iriConverter->getIriFromResource($attributes['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + if (!$iri) { + return; + } + + $this->responseTagger->addTags([$iri]); + } + } +} diff --git a/api/src/HttpCache/CacheControlListener.php b/api/src/HttpCache/CacheControlListener.php new file mode 100644 index 0000000000..494c533494 --- /dev/null +++ b/api/src/HttpCache/CacheControlListener.php @@ -0,0 +1,37 @@ +apiCacheEnabled = filter_var($apiCacheEnabled, FILTER_VALIDATE_BOOLEAN); + $this->maxHeaderSize = intval($maxHeaderSize); + } + + public function onKernelResponse(ResponseEvent $event): void { + $response = $event->getResponse(); + $headerSize = strlen($response->headers->__toString()); + + if (!$this->apiCacheEnabled || $headerSize > $this->maxHeaderSize) { + $response->headers->remove('cache-control'); + $response->setCache(['no_cache' => true, 'private' => true]); + + $response->headers->remove($this->headerKey); + + return; + } + } +} diff --git a/api/src/HttpCache/PurgeHttpCacheListener.php b/api/src/HttpCache/PurgeHttpCacheListener.php new file mode 100644 index 0000000000..840351a559 --- /dev/null +++ b/api/src/HttpCache/PurgeHttpCacheListener.php @@ -0,0 +1,251 @@ +getEntityChangeSet(); + $objectManager = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); + $associationMappings = $objectManager->getClassMetadata(ClassUtils::getClass($eventArgs->getObject()))->getAssociationMappings(); + + foreach ($changeSet as $key => $value) { + if (!isset($associationMappings[$key])) { + continue; + } + $mappings = $associationMappings[$key]; + $relatedProperty = $mappings['isOwningSide'] ? $mappings['inversedBy'] : $mappings['mappedBy']; + if (!$relatedProperty) { + continue; + } + + $this->addTagsFor($value[0], $relatedProperty); + $this->addTagsFor($value[1], $relatedProperty); + } + } + + /** + * Collects tags from inserted, updated and deleted entities, including relations. + */ + public function onFlush(OnFlushEventArgs $eventArgs): void { + $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $this->gatherResourceTags($entity); + $this->gatherRelationTags($em, $entity); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $originalEntity = $this->getOriginalEntity($entity, $em); + $this->addTagForItem($entity); + $this->gatherResourceTags($entity, $originalEntity); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $originalEntity = $this->getOriginalEntity($entity, $em); + $this->addTagForItem($originalEntity); + $this->gatherResourceTags($originalEntity); + $this->gatherRelationTags($em, $originalEntity); + } + } + + /** + * Purges tags collected during this request, and clears the tag list. + */ + public function postFlush(): void { + $this->cacheManager->flush(); + } + + /** + * Computes the original state of the entity based on the current entity and on the changeset. + */ + private function getOriginalEntity($entity, $em) { + $uow = $em->getUnitOfWork(); + $changeSet = $uow->getEntityChangeSet($entity); + $classMetadata = $em->getClassMetadata(ClassUtils::getClass($entity)); + + $originalEntity = clone $entity; + $em->detach($originalEntity); + foreach ($changeSet as $key => $value) { + $classMetadata->setFieldValue($originalEntity, $key, $value[0]); + } + + return $originalEntity; + } + + /** + * Purges all collections (GetCollection operations), in which entity is listed on top level. + * + * If oldEntity is provided, purge is only done if the IRI of the collection has changed + * (e.g. for updating period on a ScheduleEntry and the IRI changes from /periods/1/schedule_entries to /periods/2/schedule_entries) + */ + private function gatherResourceTags(object $entity, ?object $oldEntity = null): void { + $resourceClass = $this->resourceClassResolver->getResourceClass($entity); + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + $resourceIterator = $resourceMetadataCollection->getIterator(); + while ($resourceIterator->valid()) { + /** @var ApiResource $metadata */ + $metadata = $resourceIterator->current(); + + foreach ($metadata->getOperations() ?? [] as $operation) { + if ($operation instanceof GetCollection) { + $this->invalidateCollection($operation, $entity, $oldEntity); + } + } + $resourceIterator->next(); + } + } + + /** + * Purges a single collection (GetCollection operation). + * + * If oldEntity is provided, purge is only done if the IRI of the collection has changed + * (e.g. for updating period on a ScheduleEntry and the IRI changes from /periods/1/schedule_entries to /periods/2/schedule_entries) + */ + private function invalidateCollection(GetCollection $operation, object $entity, ?object $oldEntity = null): void { + $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, $operation); + + if (!$iri) { + return; + } + + if (!$oldEntity) { + $this->cacheManager->invalidateTags([$iri]); + + return; + } + + $oldIri = $this->iriConverter->getIriFromResource($oldEntity, UrlGeneratorInterface::ABS_PATH, $operation); + if ($oldIri && $iri !== $oldIri) { + $this->cacheManager->invalidateTags([$iri]); + $this->cacheManager->invalidateTags([$oldIri]); + } + } + + /** + * Invalidate all relation tags of foreign objects ($relatedObject), in which $entity appears. + * + * @psalm-suppress UndefinedClass + */ + private function gatherRelationTags(EntityManagerInterface $em, object $entity): void { + $associationMappings = $em->getClassMetadata(ClassUtils::getClass($entity))->getAssociationMappings(); + + foreach ($associationMappings as $property => $associationMapping) { + // @phpstan-ignore-next-line + if (class_exists(AssociationMapping::class) && $associationMapping instanceof AssociationMapping && ($associationMapping->targetEntity ?? null) && !$this->resourceClassResolver->isResourceClass($associationMapping->targetEntity)) { + return; + } + + // @phpstan-ignore-next-line + if (\is_array($associationMapping) + && \array_key_exists('targetEntity', $associationMapping) + && !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])) { + return; + } + + $relatedProperty = $associationMapping['isOwningSide'] ? $associationMapping['inversedBy'] : $associationMapping['mappedBy']; + if (!$relatedProperty) { + continue; + } + + if (!$this->propertyAccessor->isReadable($entity, $property)) { + continue; + } + $relatedObject = $this->propertyAccessor->getValue($entity, $property); + if ($relatedObject === $entity) { + continue; + } + + $this->addTagsFor( + $relatedObject, + $relatedProperty + ); + } + } + + private function addTagsFor(mixed $value, ?string $property = null): void { + if (!$value || \is_scalar($value)) { + return; + } + + if (!is_iterable($value)) { + $this->addTagForItem($value, $property); + + return; + } + + if ($value instanceof PersistentCollection) { + $value = clone $value; + } + + foreach ($value as $v) { + $this->addTagForItem($v, $property); + } + } + + private function addTagForItem(mixed $value, ?string $property = null): void { + if (!$this->resourceClassResolver->isResourceClass($this->getObjectClass($value))) { + return; + } + + try { + if ($value instanceof BaseEntity) { + $iri = $value->getId(); + } else { + $iri = $this->iriConverter->getIriFromResource($value); + } + if ($iri && $property) { + $iri .= self::IRI_RELATION_DELIMITER.$property; + } + if ($iri) { + $this->cacheManager->invalidateTags([$iri]); + } + } catch (InvalidArgumentException|RuntimeException) { + } + } +} diff --git a/api/src/HttpCache/ResponseTagger.php b/api/src/HttpCache/ResponseTagger.php new file mode 100644 index 0000000000..73f6c34a56 --- /dev/null +++ b/api/src/HttpCache/ResponseTagger.php @@ -0,0 +1,46 @@ + + */ +class ResponseTagger { + public function __construct( + private string $matchPath, + private SymfonyResponseTagger $responseTagger, + private RequestStack $requestStack + ) {} + + /** + * Add tags to be set on the response. + * + * Only adds tags for requests that are cacheable + * + * @param string[] $tags List of tags to add + */ + public function addTags(array $tags) { + if ($this->isCacheable()) { + $this->responseTagger->addTags($tags); + } + } + + private function isCacheable(): bool { + $request = $this->requestStack->getCurrentRequest(); + + if (!$request->isMethodCacheable()) { + return false; + } + + $requestUri = $request->getRequestUri(); + + return (bool) preg_match('{'.$this->matchPath.'}', $requestUri); + } +} diff --git a/api/src/HttpCache/TagCollector.php b/api/src/HttpCache/TagCollector.php new file mode 100644 index 0000000000..1b05aec8c7 --- /dev/null +++ b/api/src/HttpCache/TagCollector.php @@ -0,0 +1,91 @@ + + */ +class TagCollector implements TagCollectorInterface { + public const IRI_RELATION_DELIMITER = '#'; + + public function __construct(private ResponseTagger $responseTagger) {} + + /** + * Collect cache tags for cache invalidation. + * + * @param array&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array, request_uri?: string, root_operation?: Operation} $context + */ + public function collect(array $context = []): void { + $iri = $context['iri'] ?? null; + $object = $context['object'] ?? null; + + if ($object && $object instanceof HasId) { + $iri = $object->getId(); + } + + if (!$iri) { + return; + } + + if (isset($context['property_metadata'])) { + $this->addCacheTagsForRelation($context, $iri, $context['property_metadata']); + + return; + } + + // Don't include "link-only" resources (=non fully embedded resources) + if ($this->isLinkOnly($context)) { + return; + } + + $this->addCacheTagForResource($iri); + } + + private function addCacheTagForResource(string $iri): void { + $this->responseTagger->addTags([$iri]); + } + + private function addCacheTagsForRelation(array $context, string $iri, ApiProperty $propertyMetadata): void { + if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) { + foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) { + $cacheTag = $iri.PurgeHttpCacheListener::IRI_RELATION_DELIMITER.$dependency; + $this->responseTagger->addTags([$cacheTag]); + } + + return; + } + + $cacheTag = $iri.PurgeHttpCacheListener::IRI_RELATION_DELIMITER.$context['api_attribute']; + $this->responseTagger->addTags([$cacheTag]); + } + + /** + * Returns true, if a resource was normalized into a link only + * Returns false, if a resource was normalized into a fully embedded resource. + */ + private function isLinkOnly(array $context): bool { + $format = $context['format'] ?? null; + $data = $context['data'] ?? null; + + // resource was normalized into JSONAPI link format + if ('jsonapi' === $format && isset($data['data']) && \is_array($data['data']) && array_keys($data['data']) === ['type', 'id']) { + return true; + } + + // resource was normalized into a string IRI only + if (\in_array($format, ['jsonld', 'jsonhal'], true) && \is_string($data)) { + return true; + } + + return false; + } +} diff --git a/api/src/HttpCache/VarnishProxyClient.php b/api/src/HttpCache/VarnishProxyClient.php new file mode 100644 index 0000000000..93206a2829 --- /dev/null +++ b/api/src/HttpCache/VarnishProxyClient.php @@ -0,0 +1,82 @@ +apiCacheEnabled = filter_var($apiCacheEnabled, FILTER_VALIDATE_BOOLEAN); + + if ($this->isCacheEnabled()) { + parent::__construct( + new HttpDispatcher([$varnishApiUrl]), + [ + 'tag_mode' => 'purgekeys', + 'tags_header' => 'xkey-purge', + ] + ); + } + } + + public function ban(array $headers): static { + if ($this->isCacheEnabled()) { + return parent::ban($headers); + } + + return $this; + } + + public function banPath($path, $contentType = null, $hosts = null): static { + if ($this->isCacheEnabled()) { + return parent::banPath($path, $contentType, $hosts); + } + + return $this; + } + + public function invalidateTags(array $tags): static { + if ($this->isCacheEnabled()) { + return parent::invalidateTags($tags); + } + + return $this; + } + + public function purge($url, array $headers = []): static { + if ($this->isCacheEnabled()) { + return parent::purge($url, $headers); + } + + return $this; + } + + public function refresh($url, array $headers = []): static { + if ($this->isCacheEnabled()) { + return parent::refresh(${$url}, $headers); + } + + return $this; + } + + public function flush(): int { + if ($this->isCacheEnabled()) { + return parent::flush(); + } + + return 0; + } + + private function isCacheEnabled() { + return $this->apiCacheEnabled && '' !== $this->varnishApiUrl; + } +} diff --git a/api/src/Security/Voter/CampIsPrototypeVoter.php b/api/src/Security/Voter/CampIsPrototypeVoter.php index 9bdf0a1ebe..c03a1f91b4 100644 --- a/api/src/Security/Voter/CampIsPrototypeVoter.php +++ b/api/src/Security/Voter/CampIsPrototypeVoter.php @@ -4,6 +4,8 @@ use App\Entity\BelongsToCampInterface; use App\Entity\BelongsToContentNodeTreeInterface; +use App\Entity\Camp; +use App\HttpCache\ResponseTagger; use App\Util\GetCampFromContentNodeTrait; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -17,6 +19,7 @@ class CampIsPrototypeVoter extends Voter { public function __construct( private readonly EntityManagerInterface $em, + private readonly ResponseTagger $responseTagger ) {} protected function supports($attribute, $subject): bool { @@ -34,6 +37,12 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter return true; } - return $camp->isPrototype; + if ($camp->isPrototype) { + $this->responseTagger->addTags([$camp->getId()]); + + return true; + } + + return false; } } diff --git a/api/src/Security/Voter/CampRoleVoter.php b/api/src/Security/Voter/CampRoleVoter.php index eb9da39e0c..7ffcd3119d 100644 --- a/api/src/Security/Voter/CampRoleVoter.php +++ b/api/src/Security/Voter/CampRoleVoter.php @@ -4,8 +4,10 @@ use App\Entity\BelongsToCampInterface; use App\Entity\BelongsToContentNodeTreeInterface; +use App\Entity\Camp; use App\Entity\CampCollaboration; use App\Entity\User; +use App\HttpCache\ResponseTagger; use App\Util\GetCampFromContentNodeTrait; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -26,6 +28,7 @@ class CampRoleVoter extends Voter { public function __construct( private readonly EntityManagerInterface $em, + private readonly ResponseTagger $responseTagger ) {} protected function supports($attribute, $subject): bool { @@ -48,12 +51,20 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter return true; } - return $camp->collaborations + $campCollaboration = $camp->collaborations ->filter(self::withStatus(CampCollaboration::STATUS_ESTABLISHED)) ->filter(self::ofUser($user)) ->filter(self::withRole($attribute)) - ->exists(fn () => true) + ->first() ; + + if ($campCollaboration) { + $this->responseTagger->addTags([$campCollaboration->getId()]); + + return true; + } + + return false; } private static function withStatus($status) { diff --git a/api/symfony.lock b/api/symfony.lock index fd5d68de4c..b2e17ac9f2 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -168,6 +168,9 @@ "friendsofphp/proxy-manager-lts": { "version": "v1.0.3" }, + "friendsofsymfony/http-cache-bundle": { + "version": "2.16.2" + }, "gedmo/doctrine-extensions": { "version": "v3.0.5" }, @@ -260,9 +263,6 @@ "myclabs/deep-copy": { "version": "1.10.2" }, - "namshi/jose": { - "version": "7.2.3" - }, "nelmio/alice": { "version": "3.2", "recipe": { @@ -682,9 +682,6 @@ "symfony/polyfill-intl-idn": { "version": "v1.23.0" }, - "symfony/polyfill-php56": { - "version": "v1.20.0" - }, "symfony/polyfill-uuid": { "version": "v1.25.0" }, diff --git a/api/tests/Api/CampCollaborations/ListCampCollaborationsTest.php b/api/tests/Api/CampCollaborations/ListCampCollaborationsTest.php index 8ce744e353..06975241fe 100644 --- a/api/tests/Api/CampCollaborations/ListCampCollaborationsTest.php +++ b/api/tests/Api/CampCollaborations/ListCampCollaborationsTest.php @@ -114,6 +114,6 @@ public function testSqlQueryCount() { $client->enableProfiler(); $client->request('GET', '/camp_collaborations'); - $this->assertSqlQueryCount($client, 25); + $this->assertSqlQueryCount($client, 22); } } diff --git a/api/tests/Api/CampCollaborations/ReadCampCollaborationTest.php b/api/tests/Api/CampCollaborations/ReadCampCollaborationTest.php index 8c9eaf027f..a9916e6211 100644 --- a/api/tests/Api/CampCollaborations/ReadCampCollaborationTest.php +++ b/api/tests/Api/CampCollaborations/ReadCampCollaborationTest.php @@ -126,6 +126,6 @@ public function testSqlQueryCount() { $client->enableProfiler(); $client->request('GET', '/camp_collaborations/'.$campCollaboration->getId()); - $this->assertSqlQueryCount($client, 15); + $this->assertSqlQueryCount($client, 14); } } diff --git a/api/tests/Api/Camps/ReadCampTest.php b/api/tests/Api/Camps/ReadCampTest.php index ff1836eb21..df95ce9973 100644 --- a/api/tests/Api/Camps/ReadCampTest.php +++ b/api/tests/Api/Camps/ReadCampTest.php @@ -64,7 +64,7 @@ public function testGetSingleCampIsAllowedForGuest() { 'materialLists' => ['href' => '/material_lists?camp=%2Fcamps%2F'.$camp->getId()], 'campCollaborations' => ['href' => '/camp_collaborations?camp=%2Fcamps%2F'.$camp->getId()], 'periods' => ['href' => '/periods?camp=%2Fcamps%2F'.$camp->getId()], - 'categories' => ['href' => '/categories?camp=%2Fcamps%2F'.$camp->getId()], + 'categories' => ['href' => "/camps/{$camp->getId()}/categories"], ], ]); @@ -98,7 +98,7 @@ public function testGetSingleCampIsAllowedForMember() { 'materialLists' => ['href' => '/material_lists?camp=%2Fcamps%2F'.$camp->getId()], 'campCollaborations' => ['href' => '/camp_collaborations?camp=%2Fcamps%2F'.$camp->getId()], 'periods' => ['href' => '/periods?camp=%2Fcamps%2F'.$camp->getId()], - 'categories' => ['href' => '/categories?camp=%2Fcamps%2F'.$camp->getId()], + 'categories' => ['href' => "/camps/{$camp->getId()}/categories"], ], ]); } @@ -125,7 +125,7 @@ public function testGetSingleCampIsAllowedForManager() { 'materialLists' => ['href' => '/material_lists?camp=%2Fcamps%2F'.$camp->getId()], 'campCollaborations' => ['href' => '/camp_collaborations?camp=%2Fcamps%2F'.$camp->getId()], 'periods' => ['href' => '/periods?camp=%2Fcamps%2F'.$camp->getId()], - 'categories' => ['href' => '/categories?camp=%2Fcamps%2F'.$camp->getId()], + 'categories' => ['href' => "/camps/{$camp->getId()}/categories"], ], ]); } diff --git a/api/tests/Api/Categories/ListCategoriesTest.php b/api/tests/Api/Categories/ListCategoriesTest.php index a4cd979b39..5458b62cac 100644 --- a/api/tests/Api/Categories/ListCategoriesTest.php +++ b/api/tests/Api/Categories/ListCategoriesTest.php @@ -96,4 +96,33 @@ public function testListCategoriesFilteredByCampPrototypeIsAllowedForUnrelatedUs ['href' => $this->getIriFor('category1campPrototype')], ], $response->toArray()['_links']['items']); } + + public function testListCategoriesAsCampSubresourceIsAllowedForCollaborator() { + $camp = static::getFixture('camp1'); + $response = static::createClientWithCredentials()->request('GET', '/camps/'.$camp->getId().'/categories'); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + 'totalItems' => 3, + '_links' => [ + 'items' => [], + ], + '_embedded' => [ + 'items' => [], + ], + ]); + $this->assertEqualsCanonicalizing([ + ['href' => $this->getIriFor('category1')], + ['href' => $this->getIriFor('category2')], + ['href' => $this->getIriFor('categoryWithNoActivities')], + ], $response->toArray()['_links']['items']); + } + + public function testListCategoriesAsCampSubresourceIsDeniedForUnrelatedUser() { + $camp = static::getFixture('camp1'); + static::createClientWithCredentials(['email' => static::$fixtures['user4unrelated']->getEmail()]) + ->request('GET', '/camps/'.$camp->getId().'/categories') + ; + + $this->assertResponseStatusCodeSame(404); + } } diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml index 7cd0881aeb..6732d06dff 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml @@ -2329,13 +2329,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -2525,13 +2522,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -2710,13 +2704,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -2902,13 +2893,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -3501,13 +3489,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -3706,13 +3691,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -3900,13 +3882,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -4101,13 +4080,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -4449,13 +4425,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -4668,13 +4641,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -4876,13 +4846,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -5091,13 +5058,10 @@ components: type: array categories: description: 'Types of programme, such as sports activities or meal times.' - example: '["/categories/1a2b3c4d"]' - items: - example: 'https://example.com/' - format: iri-reference - type: string + example: '"/camp/1a2b3c4d/categories"' + format: iri-reference readOnly: true - type: array + type: string coachName: description: 'The name of the Y+S coach who is in charge of the camp.' example: 'Albert Anderegg' @@ -22298,6 +22262,94 @@ paths: summary: 'Creates a Camp resource.' tags: - Camp + '/camps/{campId}/categories': + get: + deprecated: false + description: 'Retrieves the collection of Category resources.' + operationId: BelongsToCamp_App\Entity\Category_get_collection + parameters: + - + allowEmptyValue: false + allowReserved: false + deprecated: false + description: 'Category identifier' + explode: false + in: path + name: campId + required: true + schema: + type: string + style: simple + - + allowEmptyValue: true + allowReserved: false + deprecated: false + description: '' + explode: false + in: query + name: camp + required: false + schema: + type: string + style: form + - + allowEmptyValue: true + allowReserved: false + deprecated: false + description: '' + explode: true + in: query + name: 'camp[]' + required: false + schema: + items: + type: string + type: array + style: form + responses: + 200: + content: + application/hal+json: + schema: + properties: + _embedded: { anyOf: [{ properties: { item: { items: { $ref: '#/components/schemas/Category.jsonhal-read' }, type: array } }, type: object }, { type: object }] } + _links: { properties: { first: { properties: { href: { format: iri-reference, type: string } }, type: object }, last: { properties: { href: { format: iri-reference, type: string } }, type: object }, next: { properties: { href: { format: iri-reference, type: string } }, type: object }, previous: { properties: { href: { format: iri-reference, type: string } }, type: object }, self: { properties: { href: { format: iri-reference, type: string } }, type: object } }, type: object } + itemsPerPage: { minimum: 0, type: integer } + totalItems: { minimum: 0, type: integer } + required: + - _embedded + - _links + type: object + application/json: + schema: + items: + $ref: '#/components/schemas/Category-read' + type: array + application/ld+json: + schema: + properties: + 'hydra:member': { items: { $ref: '#/components/schemas/Category.jsonld-read' }, type: array } + 'hydra:search': { properties: { '@type': { type: string }, 'hydra:mapping': { items: { properties: { '@type': { type: string }, property: { type: ['null', string] }, required: { type: boolean }, variable: { type: string } }, type: object }, type: array }, 'hydra:template': { type: string }, 'hydra:variableRepresentation': { type: string } }, type: object } + 'hydra:totalItems': { minimum: 0, type: integer } + 'hydra:view': { example: { '@id': string, 'hydra:first': string, 'hydra:last': string, 'hydra:next': string, 'hydra:previous': string, type: string }, properties: { '@id': { format: iri-reference, type: string }, '@type': { type: string }, 'hydra:first': { format: iri-reference, type: string }, 'hydra:last': { format: iri-reference, type: string }, 'hydra:next': { format: iri-reference, type: string }, 'hydra:previous': { format: iri-reference, type: string } }, type: object } + required: + - 'hydra:member' + type: object + application/vnd.api+json: + schema: + items: + $ref: '#/components/schemas/Category.jsonapi' + type: array + text/html: + schema: + items: + $ref: '#/components/schemas/Category-read' + type: array + description: 'Category collection' + summary: 'Retrieves the collection of Category resources.' + tags: + - Category + parameters: [] '/camps/{id}': delete: deprecated: false diff --git a/api/tests/Api/SnapshotTests/__snapshots__/test_EndpointPerformanceTest__testPerformanceDidNotChangeForStableEndpoints__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/test_EndpointPerformanceTest__testPerformanceDidNotChangeForStableEndpoints__1.yml index 3127b5156f..1e0924898b 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/test_EndpointPerformanceTest__testPerformanceDidNotChangeForStableEndpoints__1.yml +++ b/api/tests/Api/SnapshotTests/__snapshots__/test_EndpointPerformanceTest__testPerformanceDidNotChangeForStableEndpoints__1.yml @@ -4,10 +4,10 @@ /activity_progress_labels/item: 7 /activity_responsibles: 6 /activity_responsibles/item: 8 -/camps: 29 -/camps/item: 22 -/camp_collaborations: 25 -/camp_collaborations/item: 15 +/camps: 26 +/camps/item: 21 +/camp_collaborations: 22 +/camp_collaborations/item: 14 /categories: 11 /categories/item: 9 /content_types: 6 @@ -21,7 +21,7 @@ /material_lists: 6 /material_lists/item: 7 /periods: 6 -/periods/item: 18 +/periods/item: 17 /profiles: 6 /profiles/item: 6 /schedule_entries: 23 @@ -30,8 +30,8 @@ '/activities?camp=': 13 '/activity_progress_labels?camp=': 6 '/activity_responsibles?activity.camp=': 6 -'/camp_collaborations?camp=': 13 -'/camp_collaborations?activityResponsibles.activity=': 15 +'/camp_collaborations?camp=': 12 +'/camp_collaborations?activityResponsibles.activity=': 14 '/categories?camp=': 9 '/content_types?categories=': 6 '/day_responsibles?day.period=': 6 diff --git a/api/tests/HttpCache/Entity/BaseEntity.php b/api/tests/HttpCache/Entity/BaseEntity.php new file mode 100644 index 0000000000..704fb88d62 --- /dev/null +++ b/api/tests/HttpCache/Entity/BaseEntity.php @@ -0,0 +1,26 @@ +id; + } + + public function setId(string $id): void { + $this->id = $id; + } +} diff --git a/api/tests/HttpCache/Entity/ContainNonResource.php b/api/tests/HttpCache/Entity/ContainNonResource.php new file mode 100644 index 0000000000..2063142446 --- /dev/null +++ b/api/tests/HttpCache/Entity/ContainNonResource.php @@ -0,0 +1,25 @@ + + */ +#[ORM\Entity] +class ContainNonResource extends BaseEntity { + /** + * @var NotAResource + */ + public $notAResource; + + /** + * @var NotAResource[] + */ + public $collectionOfNotAResource; +} diff --git a/api/tests/HttpCache/Entity/Dummy.php b/api/tests/HttpCache/Entity/Dummy.php new file mode 100644 index 0000000000..b780638580 --- /dev/null +++ b/api/tests/HttpCache/Entity/Dummy.php @@ -0,0 +1,40 @@ + + */ +#[ORM\Entity] +class Dummy extends BaseEntity { + #[ORM\ManyToOne(targetEntity: RelatedDummy::class)] + public ?RelatedDummy $relatedDummy = null; + + /** + * @var null|RelatedOwningDummy + */ + #[ORM\OneToOne(targetEntity: RelatedOwningDummy::class, cascade: ['persist'], inversedBy: 'ownedDummy')] + public $relatedOwningDummy; + + public function getRelatedDummy(): ?RelatedDummy { + return $this->relatedDummy; + } + + public function setRelatedDummy(RelatedDummy $relatedDummy): void { + $this->relatedDummy = $relatedDummy; + } + + public function getRelatedOwningDummy() { + return $this->relatedOwningDummy; + } + + public function setRelatedOwningDummy(RelatedOwningDummy $relatedOwningDummy): void { + $this->relatedOwningDummy = $relatedOwningDummy; + } +} diff --git a/api/tests/HttpCache/Entity/DummyNoGetOperation.php b/api/tests/HttpCache/Entity/DummyNoGetOperation.php new file mode 100644 index 0000000000..831e7c8f20 --- /dev/null +++ b/api/tests/HttpCache/Entity/DummyNoGetOperation.php @@ -0,0 +1,25 @@ + + */ +class NotAResource { + public function __construct( + private $foo, + private $bar + ) {} + + public function getFoo() { + return $this->foo; + } + + public function getBar() { + return $this->bar; + } +} diff --git a/api/tests/HttpCache/Entity/RelatedDummy.php b/api/tests/HttpCache/Entity/RelatedDummy.php new file mode 100644 index 0000000000..76dd522422 --- /dev/null +++ b/api/tests/HttpCache/Entity/RelatedDummy.php @@ -0,0 +1,24 @@ + + */ +#[ORM\Entity] +class RelatedDummy extends BaseEntity { + #[ORM\OneToMany(targetEntity: Dummy::class)] + public Collection|iterable $dummies; + + public function __construct() { + $this->dummies = new ArrayCollection(); + } +} diff --git a/api/tests/HttpCache/Entity/RelatedOwningDummy.php b/api/tests/HttpCache/Entity/RelatedOwningDummy.php new file mode 100644 index 0000000000..b84370d904 --- /dev/null +++ b/api/tests/HttpCache/Entity/RelatedOwningDummy.php @@ -0,0 +1,37 @@ + + */ +#[ORM\Entity] +class RelatedOwningDummy extends BaseEntity { + #[ORM\OneToOne(targetEntity: Dummy::class, cascade: ['persist'], mappedBy: 'relatedOwningDummy')] + public ?Dummy $ownedDummy = null; + + /** + * Get owned dummy. + */ + public function getOwnedDummy(): Dummy { + return $this->ownedDummy; + } + + /** + * Set owned dummy. + * + * @param Dummy $ownedDummy the value to set + */ + public function setOwnedDummy(Dummy $ownedDummy): void { + $this->ownedDummy = $ownedDummy; + if ($this !== $this->ownedDummy->getRelatedOwningDummy()) { + $this->ownedDummy->setRelatedOwningDummy($this); + } + } +} diff --git a/api/tests/HttpCache/PurgeHttpCacheListenerTest.php b/api/tests/HttpCache/PurgeHttpCacheListenerTest.php new file mode 100644 index 0000000000..6ad010fa4e --- /dev/null +++ b/api/tests/HttpCache/PurgeHttpCacheListenerTest.php @@ -0,0 +1,397 @@ + + * + * @internal + */ +class PurgeHttpCacheListenerTest extends TestCase { + use ProphecyTrait; + + private ObjectProphecy $cacheManagerProphecy; + private ObjectProphecy $resourceClassResolverProphecy; + private ObjectProphecy $uowProphecy; + private ObjectProphecy $emProphecy; + private ObjectProphecy $propertyAccessorProphecy; + private ObjectProphecy $iriConverterProphecy; + private ObjectProphecy $metadataFactoryProphecy; + + protected function setUp(): void { + $this->cacheManagerProphecy = $this->prophesize(CacheManager::class); + $this->cacheManagerProphecy->flush(Argument::any())->willReturn(0); + + $this->resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $this->resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true); + $this->resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + + $this->uowProphecy = $this->prophesize(UnitOfWork::class); + + $this->emProphecy = $this->prophesize(EntityManagerInterface::class); + $this->emProphecy->detach(Argument::any())->willReturn(); + $this->emProphecy->getUnitOfWork()->willReturn($this->uowProphecy->reveal()); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getAssociationMappings()->willReturn([ + 'relatedDummy' => [ + 'targetEntity' => 'App\\Tests\\HttpCache\\Entity\\RelatedDummy', + 'isOwningSide' => true, + 'inversedBy' => 'dummies', + 'mappedBy' => null, + ], + 'relatedOwningDummy' => [ + 'targetEntity' => 'App\\Tests\\HttpCache\\Entity\\RelatedOwningDummy', + 'isOwningSide' => true, + 'inversedBy' => 'ownedDummy', + 'mappedBy' => null, + ], + ]); + $classMetadataProphecy->setFieldValue(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $entity = $args[0]; + $field = $args[1]; + $value = $args[2]; + $entity->{$field} = $value; + }); + $this->emProphecy->getClassMetadata(Dummy::class)->willReturn($classMetadataProphecy->reveal()); + + $this->propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $this->propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedDummy')->willReturn(true); + $this->propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(false); + $this->propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedDummy')->willReturn(null); + $this->propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(null); + + $this->metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $operation = (new GetCollection())->withShortName('Dummy')->withClass(Dummy::class); + $operation2 = (new GetCollection())->withShortName('DummyAsSubresource')->withClass(Dummy::class); + $this->metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource('Dummy')) + ->withShortName('Dummy') + ->withOperations(new Operations([ + 'get_collection' => $operation, + 'related_dummies/{id}/dummmies_get_collection' => $operation2, + ])), + ])); + + $this->iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $this->iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, $operation)->willReturn('/dummies'); + $this->iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, $operation2)->will(function ($args) { return '/related_dummies/'.$args[0]->getRelatedDummy()->getId().'/dummies'; }); + $this->iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class))->will(function ($args) { return '/dummies/'.$args[0]->getId(); }); + } + + /** + * the following tests are copied from upstream PurgeHttpCacheListenerTest + * only adjusted to passt the tests with adjusted logic from PurgeHttpCacheListener. + * Other than that, kept changes to a minimum, in order to simplify copying changes to upstream test. + */ + public function testOnFlush(): void { + $toInsert1 = new Dummy(); + $toInsert2 = new Dummy(); + + $toDelete1 = new Dummy(); + $toDelete1->setId('3'); + $toDelete2 = new Dummy(); + $toDelete2->setId('4'); + + $toDeleteNoPurge = new DummyNoGetOperation(); + $toDeleteNoPurge->setId('5'); + + $cacheManagerProphecy = $this->prophesize(CacheManager::class); + $cacheManagerProphecy->invalidateTags(['/dummies'])->willReturn($cacheManagerProphecy)->shouldBeCalled(); + $cacheManagerProphecy->invalidateTags(['/dummies/3'])->willReturn($cacheManagerProphecy)->shouldBeCalled(); + $cacheManagerProphecy->invalidateTags(['/dummies/4'])->willReturn($cacheManagerProphecy)->shouldBeCalled(); + $cacheManagerProphecy->flush(Argument::any())->willReturn(0); + + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $operation = (new GetCollection())->withShortName('Dummy')->withClass(Dummy::class); + $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource('Dummy')) + ->withShortName('Dummy') + ->withOperations(new Operations([ + 'get' => $operation, + ])), + ]))->shouldBeCalled(); + $metadataFactoryProphecy->create(DummyNoGetOperation::class)->willReturn(new ResourceMetadataCollection('DummyNoGetOperation', [ + (new ApiResource('DummyNoGetOperation')) + ->withShortName('DummyNoGetOperation'), + ]))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete1)->willReturn('/dummies/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete2)->willReturn('/dummies/4')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDeleteNoPurge)->willReturn(null)->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); + + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([$toInsert1, $toInsert2]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([$toDelete1, $toDelete2, $toDeleteNoPurge]); + $uowMock->method('getEntityChangeSet')->willReturn([]); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); + $emProphecy->detach(Argument::any())->willReturn(); + $dummyClassMetadata = new ClassMetadata(Dummy::class); + $dummyClassMetadata->mapManyToOne(['fieldName' => 'relatedDummy', 'targetEntity' => RelatedDummy::class, 'inversedBy' => 'dummies']); + $dummyClassMetadata->mapOneToOne(['fieldName' => 'relatedOwningDummy', 'targetEntity' => RelatedOwningDummy::class, 'inversedBy' => 'ownedDummy']); + + $emProphecy->getClassMetadata(Dummy::class)->willReturn($dummyClassMetadata)->shouldBeCalled(); + $emProphecy->getClassMetadata(DummyNoGetOperation::class)->willReturn(new ClassMetadata(DummyNoGetOperation::class))->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedDummy')->willReturn(true); + $propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(false); + $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedDummy')->willReturn(null); + $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(null); + + $listener = new PurgeHttpCacheListener($iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), $metadataFactoryProphecy->reveal(), $cacheManagerProphecy->reveal()); + $listener->onFlush($eventArgs); + $listener->postFlush(); + } + + public function testPreUpdate(): void { + $oldRelatedDummy = new RelatedDummy(); + $oldRelatedDummy->setId('1'); + + $newRelatedDummy = new RelatedDummy(); + $newRelatedDummy->setId('2'); + + $dummy = new Dummy(); + $dummy->setId('1'); + + $cacheManagerProphecy = $this->prophesize(CacheManager::class); + $cacheManagerProphecy->invalidateTags(['/related_dummies/old#dummies'])->shouldBeCalled()->willReturn($cacheManagerProphecy); + $cacheManagerProphecy->invalidateTags(['/related_dummies/new#dummies'])->shouldBeCalled()->willReturn($cacheManagerProphecy); + $cacheManagerProphecy->flush(Argument::any())->willReturn(0); + + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($oldRelatedDummy)->willReturn('/related_dummies/old')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($newRelatedDummy)->willReturn('/related_dummies/new')->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + + $classMetadata = new ClassMetadata(Dummy::class); + $classMetadata->mapManyToOne(['fieldName' => 'relatedDummy', 'targetEntity' => RelatedDummy::class, 'inversedBy' => 'dummies']); + $emProphecy->getClassMetadata(Dummy::class)->willReturn($classMetadata)->shouldBeCalled(); + + $changeSet = ['relatedDummy' => [$oldRelatedDummy, $newRelatedDummy]]; + $eventArgs = new PreUpdateEventArgs($dummy, $emProphecy->reveal(), $changeSet); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $listener = new PurgeHttpCacheListener($iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), $metadataFactoryProphecy->reveal(), $cacheManagerProphecy->reveal()); + $listener->preUpdate($eventArgs); + $listener->postFlush(); + } + + public function testNothingToPurge(): void { + $dummyNoGetOperation = new DummyNoGetOperation(); + $dummyNoGetOperation->setId('1'); + + $purgerProphecy = $this->prophesize(PurgerInterface::class); + $purgerProphecy->purge([])->shouldNotBeCalled(); + + $cacheManagerProphecy = $this->prophesize(CacheManager::class); + $cacheManagerProphecy->invalidateTags(Argument::any())->shouldNotBeCalled(); + $cacheManagerProphecy->flush(Argument::any())->willReturn(0); + + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + + $classMetadata = new ClassMetadata(DummyNoGetOperation::class); + $emProphecy->getClassMetadata(DummyNoGetOperation::class)->willReturn($classMetadata)->shouldBeCalled(); + + $changeSet = ['lorem' => 'ipsum']; + $eventArgs = new PreUpdateEventArgs($dummyNoGetOperation, $emProphecy->reveal(), $changeSet); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $listener = new PurgeHttpCacheListener($iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), $metadataFactoryProphecy->reveal(), $cacheManagerProphecy->reveal()); + $listener->preUpdate($eventArgs); + $listener->postFlush(); + } + + public function testNotAResourceClass(): void { + $containNonResource = new ContainNonResource(); + $nonResource = new NotAResource('foo', 'bar'); + + $cacheManagerProphecy = $this->prophesize(CacheManager::class); + $cacheManagerProphecy->invalidateTags(Argument::any())->shouldNotBeCalled(); + $cacheManagerProphecy->flush(Argument::any())->willReturn(0); + + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $metadataFactoryProphecy->create(ContainNonResource::class)->willReturn(new ResourceMetadataCollection('ContainNonResource', [ + (new ApiResource('ContainNonResource')) + ->withShortName('ContainNonResource'), + ]))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(ContainNonResource::class, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/1'); + $iriConverterProphecy->getIriFromResource($nonResource)->shouldNotBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(ContainNonResource::class))->willReturn(ContainNonResource::class)->shouldBeCalled(); + $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false)->shouldBeCalled(); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$containNonResource])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + + $dummyClassMetadata = new ClassMetadata(ContainNonResource::class); + $dummyClassMetadata->mapManyToOne(['fieldName' => 'notAResource', 'targetEntity' => NotAResource::class, 'inversedBy' => 'resources']); + $dummyClassMetadata->mapOneToMany(['fieldName' => 'collectionOfNotAResource', 'targetEntity' => NotAResource::class, 'mappedBy' => 'resource']); + $emProphecy->getClassMetadata(ContainNonResource::class)->willReturn($dummyClassMetadata); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->isReadable(Argument::type(ContainNonResource::class), 'notAResource')->willReturn(true); + $propertyAccessorProphecy->isReadable(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->willReturn(true); + $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldNotBeCalled(); + $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldNotBeCalled(); + + $listener = new PurgeHttpCacheListener($iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), $metadataFactoryProphecy->reveal(), $cacheManagerProphecy->reveal()); + $listener->onFlush($eventArgs); + } + + /** + * the following tests are additional tests, created to test specific new behavior of PurgeHttpCacheListener. + */ + public function testInsertingShouldPurgeSubresourceCollections(): void { + // given + $toInsert1 = new Dummy(); + $toInsert1->setId('1'); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId('100'); + $toInsert1->setRelatedDummy($relatedDummy); + + $this->uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert1]); + $this->uowProphecy->getScheduledEntityDeletions()->willReturn([]); + $this->uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + + // then + $this->cacheManagerProphecy->invalidateTags(['/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/100/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + + // when + $listener = new PurgeHttpCacheListener($this->iriConverterProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->propertyAccessorProphecy->reveal(), $this->metadataFactoryProphecy->reveal(), $this->cacheManagerProphecy->reveal()); + $listener->onFlush(new OnFlushEventArgs($this->emProphecy->reveal())); + $listener->postFlush(); + } + + public function testDeleteShouldPurgeSubresourceCollections(): void { + // given + $toDelete1 = new Dummy(); + $toDelete1->setId('1'); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId('100'); + $toDelete1->setRelatedDummy($relatedDummy); + + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([$toDelete1]); + $uowMock->method('getEntityChangeSet')->willReturn([]); + + $this->emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); + + // then + $this->cacheManagerProphecy->invalidateTags(['/dummies/1'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/100/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + + // when + $listener = new PurgeHttpCacheListener($this->iriConverterProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->propertyAccessorProphecy->reveal(), $this->metadataFactoryProphecy->reveal(), $this->cacheManagerProphecy->reveal()); + $listener->onFlush(new OnFlushEventArgs($this->emProphecy->reveal())); + $listener->postFlush(); + } + + public function testUpdateShouldPurgeSubresourceCollections(): void { + // given + $toUpdate1 = new Dummy(); + $toUpdate1->setId('1'); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId('100'); + $toUpdate1->setRelatedDummy($relatedDummy); + + $relatedDummyOld = new RelatedDummy(); + $relatedDummyOld->setId('99'); + + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([$toUpdate1]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([]); + $uowMock->method('getEntityChangeSet')->willReturn(['relatedDummy' => [$relatedDummyOld, $relatedDummy]]); + + $this->emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); + + // then + $this->cacheManagerProphecy->invalidateTags(['/dummies/1'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/100/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/99/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + + // when + $listener = new PurgeHttpCacheListener($this->iriConverterProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->propertyAccessorProphecy->reveal(), $this->metadataFactoryProphecy->reveal(), $this->cacheManagerProphecy->reveal()); + $listener->onFlush(new OnFlushEventArgs($this->emProphecy->reveal())); + $listener->postFlush(); + } +} diff --git a/api/tests/HttpCache/TagCollectorTest.php b/api/tests/HttpCache/TagCollectorTest.php new file mode 100644 index 0000000000..a6a6cecabf --- /dev/null +++ b/api/tests/HttpCache/TagCollectorTest.php @@ -0,0 +1,135 @@ +responseTaggerProphecy = $this->prophesize(ResponseTagger::class); + $this->tagCollector = new TagCollector($this->responseTaggerProphecy->reveal()); + } + + public function testNoTagForEmptyContext() { + // then + $this->responseTaggerProphecy->addTags(Argument::any())->shouldNotBeCalled(); + + // when + $this->tagCollector->collect([]); + } + + public function testWithIri() { + // then + $this->responseTaggerProphecy->addTags(['/test-iri'])->shouldBeCalled(); + + // when + $this->tagCollector->collect(['iri' => '/test-iri']); + } + + public function testWithBaseEntity() { + // given + $object = new Dummy(); + $object->setId('123'); + + // then + $this->responseTaggerProphecy->addTags(['123'])->shouldBeCalled(); + + // when + $this->tagCollector->collect(['iri' => '/dummy/123', 'object' => $object]); + } + + public function testWithRelation() { + // given + $object = new Dummy(); + $object->setId('123'); + + // then + $this->responseTaggerProphecy->addTags(['123#propertyName'])->shouldBeCalled(); + + // when + $this->tagCollector->collect([ + 'iri' => '/dummy/123', + 'object' => $object, + 'property_metadata' => new ApiProperty(), + 'api_attribute' => 'propertyName', + ]); + } + + public function testWithExtraCacheDependency() { + // given + $object = new Dummy(); + $object->setId('123'); + + // then + $this->responseTaggerProphecy->addTags(['123#PROPERTY_NAME'])->shouldBeCalled(); + $this->responseTaggerProphecy->addTags(['123#OTHER_DEPENDENCY'])->shouldBeCalled(); + + // when + $this->tagCollector->collect([ + 'iri' => '/dummy/123', + 'object' => $object, + 'property_metadata' => new ApiProperty( + extraProperties: [ + 'cacheDependencies' => ['PROPERTY_NAME', 'OTHER_DEPENDENCY'], + ] + ), + 'api_attribute' => 'propertyName', + ]); + } + + public function testNoTagForHalLinks() { + // then + $this->responseTaggerProphecy->addTags(Argument::any())->shouldNotBeCalled(); + + // when + $this->tagCollector->collect([ + 'iri' => '/test-iri', + 'format' => 'jsonhal', + 'data' => '/test-iri', + ]); + } + + public function testNoTagForJsonLdLinks() { + // then + $this->responseTaggerProphecy->addTags(Argument::any())->shouldNotBeCalled(); + + // when + $this->tagCollector->collect([ + 'iri' => '/test-iri', + 'format' => 'jsonld', + 'data' => '/test-iri', + ]); + } + + public function testNoTagForJsonApiLinks() { + // then + $this->responseTaggerProphecy->addTags(Argument::any())->shouldNotBeCalled(); + + // when + $this->tagCollector->collect([ + 'iri' => '/test-iri', + 'format' => 'jsonapi', + 'data' => [ + 'data' => [ + 'type' => 'dummy', + 'id' => '/test-iri', + ], + ], + ]); + } +} diff --git a/api/tests/Security/Voter/CampIsPrototypeVoterTest.php b/api/tests/Security/Voter/CampIsPrototypeVoterTest.php index 66a3667fc7..ac3e2c847f 100644 --- a/api/tests/Security/Voter/CampIsPrototypeVoterTest.php +++ b/api/tests/Security/Voter/CampIsPrototypeVoterTest.php @@ -8,13 +8,14 @@ use App\Entity\ContentNode\ColumnLayout; use App\Entity\Period; use App\Entity\User; +use App\HttpCache\ResponseTagger; use App\Security\Voter\CampIsPrototypeVoter; -use App\Security\Voter\CampRoleVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * @internal @@ -23,12 +24,14 @@ class CampIsPrototypeVoterTest extends TestCase { private CampIsPrototypeVoter $voter; private MockObject|TokenInterface $token; private EntityManagerInterface|MockObject $em; + private MockObject|ResponseTagger $responseTagger; public function setUp(): void { parent::setUp(); $this->token = $this->createMock(TokenInterface::class); $this->em = $this->createMock(EntityManagerInterface::class); - $this->voter = new CampIsPrototypeVoter($this->em); + $this->responseTagger = $this->createMock(ResponseTagger::class); + $this->voter = new CampIsPrototypeVoter($this->em, $this->responseTagger); } public function testDoesntVoteWhenAttributeWrong() { @@ -38,7 +41,7 @@ public function testDoesntVoteWhenAttributeWrong() { $result = $this->voter->vote($this->token, new Period(), ['CAMP_IS_SOMETHING_ELSE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_ABSTAIN, $result); + $this->assertEquals(VoterInterface::ACCESS_ABSTAIN, $result); } public function testDoesntVoteWhenSubjectDoesNotBelongToCamp() { @@ -48,7 +51,7 @@ public function testDoesntVoteWhenSubjectDoesNotBelongToCamp() { $result = $this->voter->vote($this->token, new CampIsPrototypeVoterTestDummy(), ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_ABSTAIN, $result); + $this->assertEquals(VoterInterface::ACCESS_ABSTAIN, $result); } public function testDoesntVoteWhenSubjectIsNull() { @@ -58,7 +61,7 @@ public function testDoesntVoteWhenSubjectIsNull() { $result = $this->voter->vote($this->token, null, ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_ABSTAIN, $result); + $this->assertEquals(VoterInterface::ACCESS_ABSTAIN, $result); } /** @@ -78,7 +81,7 @@ public function testGrantsAccessWhenGetCampYieldsNull() { $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_GRANTED, $result); + $this->assertEquals(VoterInterface::ACCESS_GRANTED, $result); } public function testDeniesAccessWhenCampIsntPrototype() { @@ -95,7 +98,7 @@ public function testDeniesAccessWhenCampIsntPrototype() { $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_DENIED, $result); + $this->assertEquals(VoterInterface::ACCESS_DENIED, $result); } public function testGrantsAccessViaBelongsToCampInterface() { @@ -108,11 +111,13 @@ public function testGrantsAccessViaBelongsToCampInterface() { $subject = $this->createMock(Period::class); $subject->method('getCamp')->willReturn($camp); + $this->responseTagger->expects($this->once())->method('addTags')->with([$camp->getId()]); + // when $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_GRANTED, $result); + $this->assertEquals(VoterInterface::ACCESS_GRANTED, $result); } public function testGrantsAccessViaBelongsToContentNodeTreeInterface() { @@ -134,7 +139,7 @@ public function testGrantsAccessViaBelongsToContentNodeTreeInterface() { $result = $this->voter->vote($this->token, $subject, ['CAMP_IS_PROTOTYPE']); // then - $this->assertEquals(CampRoleVoter::ACCESS_GRANTED, $result); + $this->assertEquals(VoterInterface::ACCESS_GRANTED, $result); } } diff --git a/api/tests/Security/Voter/CampRoleVoterTest.php b/api/tests/Security/Voter/CampRoleVoterTest.php index 84c98a6834..9c779a5713 100644 --- a/api/tests/Security/Voter/CampRoleVoterTest.php +++ b/api/tests/Security/Voter/CampRoleVoterTest.php @@ -9,6 +9,7 @@ use App\Entity\ContentNode\ColumnLayout; use App\Entity\Period; use App\Entity\User; +use App\HttpCache\ResponseTagger; use App\Security\Voter\CampRoleVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; @@ -24,12 +25,14 @@ class CampRoleVoterTest extends TestCase { private CampRoleVoter $voter; private MockObject|TokenInterface $token; private EntityManagerInterface|MockObject $em; + private MockObject|ResponseTagger $responseTagger; public function setUp(): void { parent::setUp(); $this->token = $this->createMock(TokenInterface::class); $this->em = $this->createMock(EntityManagerInterface::class); - $this->voter = new CampRoleVoter($this->em); + $this->responseTagger = $this->createMock(ResponseTagger::class); + $this->voter = new CampRoleVoter($this->em, $this->responseTagger); } public function testDoesntVoteWhenAttributeWrong() { @@ -230,6 +233,8 @@ public function testGrantsAccessViaBelongsToCampInterface() { $subject = $this->createMock(Period::class); $subject->method('getCamp')->willReturn($camp); + $this->responseTagger->expects($this->once())->method('addTags')->with([$collaboration->getId()]); + // when $result = $this->voter->vote($this->token, $subject, ['CAMP_COLLABORATOR']); diff --git a/docker-compose.yml b/docker-compose.yml index 752a8bc48f..42fde8f4fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,22 @@ services: - ./api/docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./api/public:/srv/api/public:ro + http-cache: + image: varnish:7.5.0 + container_name: 'ecamp3-http-cache' + depends_on: + - caddy + volumes: + - ./api/docker/varnish/vcl/:/etc/varnish/:ro + ports: + - target: 8080 + published: 3004 + protocol: tcp + command: -a :8081,HTTP -p http_max_hdr=96 + environment: + - COOKIE_PREFIX=localhost_ + - VARNISH_HTTP_PORT=8080 + pdf: image: node:20.13.1 container_name: 'ecamp3-pdf' diff --git a/e2e/cypress.config.js b/e2e/cypress.config.js index 0f5278dbe8..308dd0cc56 100644 --- a/e2e/cypress.config.js +++ b/e2e/cypress.config.js @@ -24,5 +24,6 @@ module.exports = defineConfig({ env: { PRINT_URL: 'http://localhost:3000/print', API_ROOT_URL: 'http://localhost:3000/api', + API_ROOT_URL_CACHED: 'http://localhost:3004', }, }) diff --git a/e2e/specs/httpCache.cy.js b/e2e/specs/httpCache.cy.js new file mode 100644 index 0000000000..4b0fc346be --- /dev/null +++ b/e2e/specs/httpCache.cy.js @@ -0,0 +1,244 @@ +describe('HTTP cache tests', () => { + it('caches /content_types separately for each login', () => { + const uri = '/api/content_types' + + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + + // first request is a cache miss + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers.xkey).to.eq( + 'c462edd869f3 5e2028c55ee4 a4211c112939 f17470519474 1a0f84e322c8 3ef17bd1df72 4f0c657fecef 44dcc7493c65 cfccaecd4bad 318e064ea0c9 /api/content_types' + ) + expect(headers['x-cache']).to.eq('MISS') + cy.readFile('./specs/responses/content_types_collection.json').then((data) => + expect(response.body).to.deep.equal(data) + ) + }) + + // second request is a cache hit + cy.expectCacheHit(uri) + + // request with a new user is a cache miss + cy.login('castor@example.com') + cy.expectCacheMiss(uri) + }) + + it('caches /content_types/318e064ea0c9', () => { + const uri = '/api/content_types/318e064ea0c9' + + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + + // first request is a cache miss + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers.xkey).to.eq('318e064ea0c9') + expect(headers['x-cache']).to.eq('MISS') + cy.readFile('./specs/responses/content_types_entity.json').then((data) => + expect(response.body).to.deep.equal(data) + ) + }) + + // second request is a cache hit + cy.expectCacheHit(uri) + }) + + it('caches /camp/{campId}/categories separately for each login', () => { + const uri = '/api/camps/3c79b99ab424/categories' + + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + + // first request is a cache miss + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers.xkey).to.eq( + /* campCollaboration for test@example.com */ + 'b0bdb7202a9d ' + + /* Category ES */ + 'ebfd46a1c181 ebfd46a1c181#camp ebfd46a1c181#preferredContentTypes 9d7b3a220fb4 9d7b3a220fb4#root 9d7b3a220fb4#parent 9d7b3a220fb4#children 9d7b3a220fb4#contentType ebfd46a1c181#rootContentNode ebfd46a1c181#contentNodes ' + + /* Category LA */ + '1a869b162875 1a869b162875#camp 1a869b162875#preferredContentTypes be9b6b7f23f6 be9b6b7f23f6#root be9b6b7f23f6#parent be9b6b7f23f6#children be9b6b7f23f6#contentType 1a869b162875#rootContentNode 1a869b162875#contentNodes ' + + /* Category LP */ + 'dfa531302823 dfa531302823#camp dfa531302823#preferredContentTypes 63cbc734fa04 63cbc734fa04#root 63cbc734fa04#parent 63cbc734fa04#children 63cbc734fa04#contentType dfa531302823#rootContentNode dfa531302823#contentNodes ' + + /* Category LS */ + 'a023e85227ac a023e85227ac#camp a023e85227ac#preferredContentTypes 2cce9e17a368 2cce9e17a368#root 2cce9e17a368#parent 2cce9e17a368#children 2cce9e17a368#contentType a023e85227ac#rootContentNode a023e85227ac#contentNodes ' + + /* collection URI (for detecting addition of new categories) */ + '/api/camps/3c79b99ab424/categories' + ) + expect(headers['x-cache']).to.eq('MISS') + cy.readFile('./specs/responses/categories_collection.json').then((data) => + expect(response.body).to.deep.equal(data) + ) + }) + + // second request is a cache hit + cy.expectCacheHit(uri) + + // request with a new user is a cache miss + cy.login('castor@example.com') + cy.expectCacheMiss(uri) + }) + + it('invalidates /camp/{campId}/categories for all users on category patch', () => { + const uri = '/api/camps/9c2447aefe38/categories' + + // bring data into defined state + Cypress.session.clearAllSavedSessions() + cy.login('bruce@wayne.com') + cy.apiPatch('/api/categories/c5e1bc565094', { + name: 'old_name', + }) + + // warm up cache + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + cy.login('felicity@smoak.com') + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // touch category + cy.apiPatch('/api/categories/c5e1bc565094', { + name: 'new_name', + }) + + // ensure cache was invalidated + cy.expectCacheMiss(uri) + cy.login('bruce@wayne.com') + cy.expectCacheMiss(uri) + }) + + it('invalidates /camp/{campId}/categories for new contentNode child', () => { + const uri = '/api/camps/3c79b99ab424/categories' + + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + + // warm up cache + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // add new child to root content node (9d7b3a220fb4) of category (ebfd46a1c181) + cy.apiPost('/api/content_node/column_layouts', { + parent: '/api/content_node/column_layouts/9d7b3a220fb4', + slot: '1', + contentType: '/api/content_types/f17470519474', + }).then((response) => { + const newContentNodeUri = response.body._links.self.href + + // ensure cache was invalidated + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // delete newly created contentNode + cy.apiDelete(newContentNodeUri) + + // ensure cache was invalidated + cy.expectCacheMiss(uri) + }) + }) + + it('invalidates /camp/{campId}/categories for new category', () => { + const uri = '/api/camps/3c79b99ab424/categories' + + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + + // warm up cache + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // add new category to camp + cy.apiPost('/api/categories', { + camp: '/api/camps/3c79b99ab424', + short: 'new', + name: 'new Category', + color: '#000000', + numberingStyle: '1', + }).then((response) => { + const newContentNodeUri = response.body._links.self.href + + // ensure cache was invalidated + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // delete newly created contentNode + cy.apiDelete(newContentNodeUri) + + // ensure cache was invalidated + cy.expectCacheMiss(uri) + }) + }) + + it('invalidates cached data when user leaves a camp', () => { + Cypress.session.clearAllSavedSessions() + const uri = '/api/camps/3c79b99ab424/categories' + + cy.intercept('PATCH', '/api/camp_collaborations/**').as('camp_collaboration') + cy.intercept('PATCH', '/api/invitations/**').as('invitations') + + // warm up cache + cy.login('castor@example.com') + cy.expectCacheMiss(uri) + cy.expectCacheHit(uri) + + // deactivate Castor + cy.login('test@example.com') + cy.visit('/camps/3c79b99ab424/GRGR/admin/collaborators') + cy.get('.v-list-item__title:contains("Castor")').click() + cy.get('button:contains("Deaktivieren")').click() + cy.get('div[role=alert]').find('button').contains('Deaktivieren').click() + cy.wait('@camp_collaboration') + + // ensure cache was invalidated + cy.login('castor@example.com') + cy.request({ + url: Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal', + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(404) + }) + + // delete old emails + cy.request({ + url: 'localhost:3000/mail/email/all', + method: 'DELETE', + }) + + // invite Castor + cy.login('test@example.com') + cy.visit('/camps/3c79b99ab424/GRGR/admin/collaborators') + cy.get('.v-list-item__title:contains("Castor")').click() + cy.get('button:contains("Erneut einladen")').click() + cy.wait('@camp_collaboration') + + // accept invitation as Castor + cy.login('castor@example.com') + + cy.request({ + url: 'localhost:3000/mail/email', + }).then((response) => { + const emailHtmlContent = response.body[0].html + cy.document().then((document) => { + document.documentElement.innerHTML = emailHtmlContent + }) + + cy.get('a:contains("Einladung beantworten")').invoke('removeAttr', 'target').click() + + cy.get('button:contains("Einladung mit aktuellem Account akzeptieren")').click() + cy.wait('@invitations') + cy.visit('/camps') + cy.contains('GRGR') + }) + }) + + it("doesn't cache /camps", () => { + const uri = '/api/camps' + Cypress.session.clearAllSavedSessions() + cy.login('test@example.com') + cy.expectCachePass(uri) + }) +}) diff --git a/e2e/specs/responses/categories_collection.json b/e2e/specs/responses/categories_collection.json new file mode 100644 index 0000000000..a6cdad1075 --- /dev/null +++ b/e2e/specs/responses/categories_collection.json @@ -0,0 +1,254 @@ +{ + "_links": { + "self": { + "href": "/api/camps/3c79b99ab424/categories.jsonhal" + }, + "items": [ + { + "href": "/api/categories/ebfd46a1c181" + }, + { + "href": "/api/categories/1a869b162875" + }, + { + "href": "/api/categories/dfa531302823" + }, + { + "href": "/api/categories/a023e85227ac" + } + ] + }, + "totalItems": 4, + "_embedded": { + "items": [ + { + "_links": { + "self": { + "href": "/api/categories/ebfd46a1c181" + }, + "camp": { + "href": "/api/camps/3c79b99ab424" + }, + "preferredContentTypes": { + "href": "/api/content_types?categories=%2Fapi%2Fcategories%2Febfd46a1c181" + }, + "rootContentNode": { + "href": "/api/content_node/column_layouts/9d7b3a220fb4" + }, + "contentNodes": { + "href": "/api/content_nodes?root=%2Fapi%2Fcontent_node%2Fcolumn_layouts%2F9d7b3a220fb4" + } + }, + "_embedded": { + "rootContentNode": { + "_links": { + "self": { + "href": "/api/content_node/column_layouts/9d7b3a220fb4" + }, + "root": { + "href": "/api/content_node/column_layouts/9d7b3a220fb4" + }, + "parent": null, + "children": [], + "contentType": { + "href": "/api/content_types/f17470519474" + } + }, + "data": { + "columns": [ + { + "slot": "1", + "width": 12 + } + ] + }, + "slot": null, + "position": 0, + "instanceName": null, + "id": "9d7b3a220fb4", + "contentTypeName": "ColumnLayout" + } + }, + "short": "ES", + "name": "Essen", + "color": "#BBBBBB", + "numberingStyle": "-", + "id": "ebfd46a1c181" + }, + { + "_links": { + "self": { + "href": "/api/categories/1a869b162875" + }, + "camp": { + "href": "/api/camps/3c79b99ab424" + }, + "preferredContentTypes": { + "href": "/api/content_types?categories=%2Fapi%2Fcategories%2F1a869b162875" + }, + "rootContentNode": { + "href": "/api/content_node/column_layouts/be9b6b7f23f6" + }, + "contentNodes": { + "href": "/api/content_nodes?root=%2Fapi%2Fcontent_node%2Fcolumn_layouts%2Fbe9b6b7f23f6" + } + }, + "_embedded": { + "rootContentNode": { + "_links": { + "self": { + "href": "/api/content_node/column_layouts/be9b6b7f23f6" + }, + "root": { + "href": "/api/content_node/column_layouts/be9b6b7f23f6" + }, + "parent": null, + "children": [ + { + "href": "/api/content_node/responsive_layouts/179ba93a4bb9" + } + ], + "contentType": { + "href": "/api/content_types/f17470519474" + } + }, + "data": { + "columns": [ + { + "slot": "1", + "width": 12 + } + ] + }, + "slot": null, + "position": 0, + "instanceName": null, + "id": "be9b6b7f23f6", + "contentTypeName": "ColumnLayout" + } + }, + "short": "LA", + "name": "Lageraktivität", + "color": "#FF9800", + "numberingStyle": "A", + "id": "1a869b162875" + }, + { + "_links": { + "self": { + "href": "/api/categories/dfa531302823" + }, + "camp": { + "href": "/api/camps/3c79b99ab424" + }, + "preferredContentTypes": { + "href": "/api/content_types?categories=%2Fapi%2Fcategories%2Fdfa531302823" + }, + "rootContentNode": { + "href": "/api/content_node/column_layouts/63cbc734fa04" + }, + "contentNodes": { + "href": "/api/content_nodes?root=%2Fapi%2Fcontent_node%2Fcolumn_layouts%2F63cbc734fa04" + } + }, + "_embedded": { + "rootContentNode": { + "_links": { + "self": { + "href": "/api/content_node/column_layouts/63cbc734fa04" + }, + "root": { + "href": "/api/content_node/column_layouts/63cbc734fa04" + }, + "parent": null, + "children": [ + { + "href": "/api/content_node/responsive_layouts/801027c511e6" + } + ], + "contentType": { + "href": "/api/content_types/f17470519474" + } + }, + "data": { + "columns": [ + { + "slot": "1", + "width": 12 + } + ] + }, + "slot": null, + "position": 0, + "instanceName": null, + "id": "63cbc734fa04", + "contentTypeName": "ColumnLayout" + } + }, + "short": "LP", + "name": "Lagerprogramm", + "color": "#90B7E4", + "numberingStyle": "1", + "id": "dfa531302823" + }, + { + "_links": { + "self": { + "href": "/api/categories/a023e85227ac" + }, + "camp": { + "href": "/api/camps/3c79b99ab424" + }, + "preferredContentTypes": { + "href": "/api/content_types?categories=%2Fapi%2Fcategories%2Fa023e85227ac" + }, + "rootContentNode": { + "href": "/api/content_node/column_layouts/2cce9e17a368" + }, + "contentNodes": { + "href": "/api/content_nodes?root=%2Fapi%2Fcontent_node%2Fcolumn_layouts%2F2cce9e17a368" + } + }, + "_embedded": { + "rootContentNode": { + "_links": { + "self": { + "href": "/api/content_node/column_layouts/2cce9e17a368" + }, + "root": { + "href": "/api/content_node/column_layouts/2cce9e17a368" + }, + "parent": null, + "children": [ + { + "href": "/api/content_node/responsive_layouts/80d79bc8f484" + } + ], + "contentType": { + "href": "/api/content_types/f17470519474" + } + }, + "data": { + "columns": [ + { + "slot": "1", + "width": 12 + } + ] + }, + "slot": null, + "position": 0, + "instanceName": "est", + "id": "2cce9e17a368", + "contentTypeName": "ColumnLayout" + } + }, + "short": "LS", + "name": "Lagersport", + "color": "#4DBB52", + "numberingStyle": "1", + "id": "a023e85227ac" + } + ] + } +} diff --git a/e2e/specs/responses/content_types_collection.json b/e2e/specs/responses/content_types_collection.json new file mode 100644 index 0000000000..f209e6a3b5 --- /dev/null +++ b/e2e/specs/responses/content_types_collection.json @@ -0,0 +1,174 @@ +{ + "_links": { + "self": { + "href": "/api/content_types.jsonhal" + }, + "items": [ + { + "href": "/api/content_types/c462edd869f3" + }, + { + "href": "/api/content_types/5e2028c55ee4" + }, + { + "href": "/api/content_types/a4211c112939" + }, + { + "href": "/api/content_types/f17470519474" + }, + { + "href": "/api/content_types/1a0f84e322c8" + }, + { + "href": "/api/content_types/3ef17bd1df72" + }, + { + "href": "/api/content_types/4f0c657fecef" + }, + { + "href": "/api/content_types/44dcc7493c65" + }, + { + "href": "/api/content_types/cfccaecd4bad" + }, + { + "href": "/api/content_types/318e064ea0c9" + } + ] + }, + "totalItems": 10, + "_embedded": { + "items": [ + { + "_links": { + "self": { + "href": "/api/content_types/c462edd869f3" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2Fc462edd869f3" + } + }, + "name": "LearningObjectives", + "active": true, + "id": "c462edd869f3" + }, + { + "_links": { + "self": { + "href": "/api/content_types/5e2028c55ee4" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2F5e2028c55ee4" + } + }, + "name": "LearningTopics", + "active": true, + "id": "5e2028c55ee4" + }, + { + "_links": { + "self": { + "href": "/api/content_types/a4211c112939" + }, + "contentNodes": { + "href": "/api/content_node/responsive_layouts?contentType=%2Fapi%2Fcontent_types%2Fa4211c112939" + } + }, + "name": "ResponsiveLayout", + "active": true, + "id": "a4211c112939" + }, + { + "_links": { + "self": { + "href": "/api/content_types/f17470519474" + }, + "contentNodes": { + "href": "/api/content_node/column_layouts?contentType=%2Fapi%2Fcontent_types%2Ff17470519474" + } + }, + "name": "ColumnLayout", + "active": true, + "id": "f17470519474" + }, + { + "_links": { + "self": { + "href": "/api/content_types/1a0f84e322c8" + }, + "contentNodes": { + "href": "/api/content_node/multi_selects?contentType=%2Fapi%2Fcontent_types%2F1a0f84e322c8" + } + }, + "name": "LAThematicArea", + "active": true, + "id": "1a0f84e322c8" + }, + { + "_links": { + "self": { + "href": "/api/content_types/3ef17bd1df72" + }, + "contentNodes": { + "href": "/api/content_node/material_nodes?contentType=%2Fapi%2Fcontent_types%2F3ef17bd1df72" + } + }, + "name": "Material", + "active": true, + "id": "3ef17bd1df72" + }, + { + "_links": { + "self": { + "href": "/api/content_types/4f0c657fecef" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2F4f0c657fecef" + } + }, + "name": "Notes", + "active": true, + "id": "4f0c657fecef" + }, + { + "_links": { + "self": { + "href": "/api/content_types/44dcc7493c65" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2F44dcc7493c65" + } + }, + "name": "SafetyConcept", + "active": true, + "id": "44dcc7493c65" + }, + { + "_links": { + "self": { + "href": "/api/content_types/cfccaecd4bad" + }, + "contentNodes": { + "href": "/api/content_node/storyboards?contentType=%2Fapi%2Fcontent_types%2Fcfccaecd4bad" + } + }, + "name": "Storyboard", + "active": true, + "id": "cfccaecd4bad" + }, + { + "_links": { + "self": { + "href": "/api/content_types/318e064ea0c9" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2F318e064ea0c9" + } + }, + "name": "Storycontext", + "active": true, + "id": "318e064ea0c9" + } + ] + } +} diff --git a/e2e/specs/responses/content_types_entity.json b/e2e/specs/responses/content_types_entity.json new file mode 100644 index 0000000000..8e76a52dcd --- /dev/null +++ b/e2e/specs/responses/content_types_entity.json @@ -0,0 +1,13 @@ +{ + "_links": { + "self": { + "href": "/api/content_types/318e064ea0c9" + }, + "contentNodes": { + "href": "/api/content_node/single_texts?contentType=%2Fapi%2Fcontent_types%2F318e064ea0c9" + } + }, + "name": "Storycontext", + "active": true, + "id": "318e064ea0c9" +} diff --git a/e2e/support/commands.js b/e2e/support/commands.js index cd8e15779f..1e5cc9faa9 100644 --- a/e2e/support/commands.js +++ b/e2e/support/commands.js @@ -37,3 +37,53 @@ Cypress.Commands.add('login', (identifier) => { Cypress.Commands.add('moveDownloads', () => { cy.task('moveDownloads', `${Cypress.spec.name}/${Cypress.currentTest.title}`) }) + +Cypress.Commands.add('expectCacheHit', (uri) => { + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers['x-cache']).to.eq('HIT') + }) +}) + +Cypress.Commands.add('expectCacheMiss', (uri) => { + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers['x-cache']).to.eq('MISS') + }) +}) + +Cypress.Commands.add('expectCachePass', (uri) => { + cy.request(Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal').then((response) => { + const headers = response.headers + expect(headers['x-cache']).to.eq('PASS') + }) +}) + +Cypress.Commands.add('apiPatch', (uri, body) => { + cy.request({ + method: 'PATCH', + url: Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal', + body, + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + }) +}) + +Cypress.Commands.add('apiPost', (uri, body) => { + cy.request({ + method: 'POST', + url: Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal', + body, + headers: { + 'Content-Type': 'application/hal+json', + }, + }) +}) + +Cypress.Commands.add('apiDelete', (uri) => { + cy.request({ + method: 'DELETE', + url: Cypress.env('API_ROOT_URL_CACHED') + uri + '.jsonhal', + }) +}) diff --git a/print/print.env b/print/print.env index 8f6db3f4fa..04e3244978 100644 --- a/print/print.env +++ b/print/print.env @@ -1,4 +1,4 @@ -NUXT_INTERNAL_API_ROOT_URL=http://caddy:3000/api +NUXT_INTERNAL_API_ROOT_URL=http://http-cache:8080/api NUXT_SENTRY_PRINT_DSN= NUXT_SENTRY_ENVIRONMENT=local NUXT_BROWSER_WS_ENDPOINT=ws://browserless:3000