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