From cf1261444f2e2e6bbcfd1fa6fecb1321eacd688f Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Fri, 28 Aug 2026 12:37:54 +0200 Subject: [PATCH 01/59] Initial commit --- Notes.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Notes.md diff --git a/Notes.md b/Notes.md new file mode 100644 index 0000000..bbd29b1 --- /dev/null +++ b/Notes.md @@ -0,0 +1,9 @@ +# Notes + +## GeoJSON + +* [New Features and Changes in NGSI-LD v1.3.1](https://docbox.etsi.org/ISG/CIM/Open/NGSI-LD_API_v1.3.1-New_Features_and_Changes.pdf) +* https://github.com/ScorpioBroker/ScorpioBroker/issues/128 + * https://github.com/ScorpioBroker/ScorpioBroker/issues/576 + +Is a `location` property the way to store location data on an entity? From 32dea1e3c603215fac695918b27dae78207e9dd4 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Fri, 28 Aug 2026 12:58:14 +0200 Subject: [PATCH 02/59] Added DDEV and Symfony --- .ddev/config.yaml | 295 ++++ .editorconfig | 17 + .env | 27 + .env.dev | 4 + .gitignore | 10 + README.md | 7 + bin/console | 21 + composer.json | 71 + composer.lock | 2608 ++++++++++++++++++++++++++++++++ config/bundles.php | 5 + config/packages/cache.yaml | 19 + config/packages/framework.yaml | 15 + config/packages/routing.yaml | 10 + config/preload.php | 5 + config/reference.php | 826 ++++++++++ config/routes.yaml | 11 + config/routes/framework.yaml | 4 + config/services.yaml | 23 + public/index.php | 9 + src/Controller/.gitignore | 0 src/Kernel.php | 19 + symfony.lock | 60 + 22 files changed, 4066 insertions(+) create mode 100644 .ddev/config.yaml create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .env.dev create mode 100644 .gitignore create mode 100755 bin/console create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/bundles.php create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/preload.php create mode 100644 config/reference.php create mode 100644 config/routes.yaml create mode 100644 config/routes/framework.yaml create mode 100644 config/services.yaml create mode 100644 public/index.php create mode 100644 src/Controller/.gitignore create mode 100644 src/Kernel.php create mode 100644 symfony.lock diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 0000000..0594bc0 --- /dev/null +++ b/.ddev/config.yaml @@ -0,0 +1,295 @@ +name: enter +type: symfony +docroot: public +php_version: "8.5" +webserver_type: nginx-fpm +xdebug_enabled: false +additional_hostnames: [] +additional_fqdns: [] +database: + type: mariadb + version: "11.8" +use_dns_when_possible: true +composer_version: "2" +web_environment: [] +nodejs_version: "24" +corepack_enable: false + +# Key features of DDEV's config.yaml: + +# name: # Name of the project, automatically provides +# http://projectname.ddev.site and https://projectname.ddev.site +# If the name is omitted, the project will take the name of the enclosing directory, +# which is useful if you want to have a copy of the project side by side with this one. + +# type: # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock +# See https://docs.ddev.com/en/stable/users/quickstart/ for more +# information on the different project types + +# docroot: # Relative path to the directory containing index.php. + +# php_version: "8.4" # PHP version to use, "5.6" through "8.5" + +# You can explicitly specify the webimage but this +# is not recommended, as the images are often closely tied to DDEV's behavior, +# so this can break upgrades. + +# webimage: +# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason. +# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.* + +# database: +# type: # mysql, mariadb, postgres +# version: # database version, like "10.11" or "8.0" +# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3 +# MySQL versions can be 5.5-8.0, 8.4 +# PostgreSQL versions can be 9-18 + +# router_http_port: # Port to be used for http (defaults to global configuration, usually 80) +# router_https_port: # Port for https (defaults to global configuration, usually 443) + +# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, +# as leaving Xdebug enabled all the time is a big performance hit. + +# xhgui_http_port: "8143" +# xhgui_https_port: "8142" +# The XHGui ports can be changed from the default 8143 and 8142 +# Very rarely used + +# host_xhgui_port: "8142" +# Can be used to change the host binding port of the XHGui +# application. Rarely used; only when port conflict and +# bind_all_ports is used (normally with router disabled) + +# xhprof_mode: [prepend|xhgui|global] +# Default is "xhgui" + +# webserver_type: nginx-fpm, apache-fpm, generic + +# timezone: Europe/Berlin +# If timezone is unset, DDEV will attempt to derive it from the host system timezone +# using the $TZ environment variable or the /etc/localtime symlink. +# This is the timezone used in the containers and by PHP; +# it can be set to any valid timezone, +# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# For example Europe/Dublin or MST7MDT + +# composer_root: +# Relative path to the Composer root directory from the project root. This is +# the directory which contains the composer.json and where all Composer related +# commands are executed. + +# composer_version: "2" +# You can set it to "" or "2" (default) for Composer v2 +# to use the latest major version available at the time your container is built. +# It is also possible to use any other Composer version channel. This includes: +# - 2.2 (latest Composer LTS version) +# - stable +# - preview +# - snapshot +# Alternatively, an explicit Composer version may be specified, for example "2.2.18". +# To reinstall Composer after the image was built, run "ddev utility rebuild". + +# nodejs_version: "24" +# change from the default system Node.js version to any other version. +# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information +# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation. + +# corepack_enable: false +# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm + +# additional_hostnames: +# - somename +# - someothername +# would provide http and https URLs for "somename.ddev.site" +# and "someothername.ddev.site". + +# additional_fqdns: +# - example.com +# - sub1.example.com +# would provide http and https URLs for "example.com" and "sub1.example.com" +# Please take care with this because it can cause great confusion. + +# upload_dirs: "custom/upload/dir" +# +# upload_dirs: +# - custom/upload/dir +# - ../private +# +# would set the destination paths for ddev import-files to /custom/upload/dir +# When Mutagen is enabled this path is bind-mounted so that all the files +# in the upload_dirs don't have to be synced into Mutagen. + +# disable_upload_dirs_warning: false +# If true, turns off the normal warning that says +# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set" + +# ddev_version_constraint: "" +# Example: +# ddev_version_constraint: ">= 1.24.8" +# This will enforce that the running ddev version is within this constraint. +# See https://github.com/Masterminds/semver#checking-version-constraints for +# supported constraint formats + +# working_dir: +# web: /var/www/html +# db: /home +# would set the default working directory for the web and db services. +# These values specify the destination directory for ddev ssh and the +# directory in which commands passed into ddev exec are run. + +# omit_containers: [db, ddev-ssh-agent] +# Currently only these containers are supported. Some containers can also be +# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit +# the "db" container, several standard features of DDEV that access the +# database container will be unusable. In the global configuration it is also +# possible to omit ddev-router, but not here. + +# performance_mode: "global" +# DDEV offers performance optimization strategies to improve the filesystem +# performance depending on your host system. Should be configured globally. +# +# If set, will override the global config. Possible values are: +# - "global": uses the value from the global config. +# - "none": disables performance optimization for this project. +# - "mutagen": enables Mutagen for this project. +# +# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen + +# fail_on_hook_fail: False +# Decide whether 'ddev start' should be interrupted by a failing hook + +# host_https_port: "59002" +# The host port binding for https can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_webserver_port: "59001" +# The host port binding for the ddev-webserver can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_db_port: "59002" +# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic +# unless explicitly specified. + +# mailpit_http_port: "8025" +# mailpit_https_port: "8026" +# The Mailpit ports can be changed from the default 8025 and 8026 + +# host_mailpit_port: "8025" +# The mailpit port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be bound directly to localhost if specified here. + +# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac'] +# Extra Debian packages that are needed in the webimage can be added here + +# dbimage_extra_packages: [netcat, telnet, sudo] +# Extra Debian packages that are needed in the dbimage can be added here + +# use_dns_when_possible: true +# If the host has internet access and the domain configured can +# successfully be looked up, DNS will be used for hostname resolution +# instead of editing /etc/hosts +# Defaults to true + +# project_tld: ddev.site +# The top-level domain used for project URLs +# The default "ddev.site" allows DNS lookup via a wildcard + +# share_default_provider: ngrok +# The default share provider to use for "ddev share" +# Defaults to global configuration, usually "ngrok" +# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/ + +# share_provider_args: --basic-auth username:pass1234 +# Provide extra flags to the share provider script +# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args + +# disable_settings_management: false +# If true, DDEV will not create CMS-specific settings files like +# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php +# In this case the user must provide all such settings. + +# You can inject environment variables into the web container with: +# web_environment: +# - SOMEENV=somevalue +# - SOMEOTHERENV=someothervalue + +# no_project_mount: false +# (Experimental) If true, DDEV will not mount the project into the web container; +# the user is responsible for mounting it manually or via a script. +# This is to enable experimentation with alternate file mounting strategies. +# For advanced users only! + +# bind_all_interfaces: false +# If true, host ports will be bound on all network interfaces, +# not the localhost interface only. This means that ports +# will be available on the local network if the host firewall +# allows it. + +# default_container_timeout: 120 +# The default time that DDEV waits for all containers to become ready can be increased from +# the default 120. This helps in importing huge databases, for example. + +#web_extra_exposed_ports: +#- name: nodejs +# container_port: 3000 +# http_port: 2999 +# https_port: 3000 +#- name: something +# container_port: 4000 +# https_port: 4000 +# http_port: 3999 +# Allows a set of extra ports to be exposed via ddev-router +# Fill in all three fields even if you don’t intend to use the https_port! +# If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start. +# +# The port behavior on the ddev-webserver must be arranged separately, for example +# using web_extra_daemons. +# For example, with a web app on port 3000 inside the container, this config would +# expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 +# web_extra_exposed_ports: +# - name: myapp +# container_port: 3000 +# http_port: 9998 +# https_port: 9999 + +#web_extra_daemons: +#- name: "http-1" +# command: "/var/www/html/node_modules/.bin/http-server -p 3000" +# directory: /var/www/html +#- name: "http-2" +# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" +# directory: /var/www/html + +# override_config: false +# By default, config.*.yaml files are *merged* into the configuration +# But this means that some things can't be overridden +# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge +# and you can't erase existing hooks or all environment variables. +# However, with "override_config: true" in a particular config.*.yaml file, +# 'use_dns_when_possible: false' can override the existing values, and +# hooks: +# post-start: [] +# or +# web_environment: [] +# or +# additional_hostnames: [] +# can have their intended effect. 'override_config' affects only behavior of the +# config.*.yaml file it exists in. + +# Many DDEV commands can be extended to run tasks before or after the +# DDEV command is executed, for example "post-start", "post-import-db", +# "pre-composer", "post-composer" +# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more +# information on the commands that can be extended and the tasks you can define +# for them. Example: +#hooks: +## Un-comment to consume async message. +# post-start: +# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env new file mode 100644 index 0000000..9e19457 --- /dev/null +++ b/.env @@ -0,0 +1,27 @@ +# In all environments, the following files are loaded if they exist, +# the latter taking precedence over the former: +# +# * .env contains default values for the environment variables needed by the app +# * .env.local uncommitted file with local overrides +# * .env.$APP_ENV committed environment-specific defaults +# * .env.$APP_ENV.local uncommitted environment-specific overrides +# +# Real environment variables win over .env files. +# +# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. +# https://symfony.com/doc/current/configuration/secrets.html +# +# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). +# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration + +###> symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET= +APP_SHARE_DIR=var/share +###< symfony/framework-bundle ### + +###> symfony/routing ### +# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. +# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands +DEFAULT_URI=http://localhost +###< symfony/routing ### diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..03f06e2 --- /dev/null +++ b/.env.dev @@ -0,0 +1,4 @@ + +###> symfony/framework-bundle ### +APP_SECRET=c28dc9f45cdb321c0a49317ec3823c59 +###< symfony/framework-bundle ### diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a67f91e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### diff --git a/README.md b/README.md index 853c075..fa913b3 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ # Enter + +We use [DDEV](https://ddev.com/) for development: + +``` shell +ddev start +ddev launch +``` diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d8d530e --- /dev/null +++ b/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php +=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "symfony/console": "8.1.*", + "symfony/dotenv": "8.1.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "8.1.*", + "symfony/runtime": "8.1.*", + "symfony/yaml": "8.1.*" + }, + "require-dev": { + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.1.*" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..2ec33ce --- /dev/null +++ b/composer.lock @@ -0,0 +1,2608 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8f2d2bb175ffe409da49e7034d1f3a93", + "packages": [ + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/cache", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/c12be586c0e4798e649e7501aa9181ddc9d93310", + "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-19T08:32:28+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/config", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:59:12+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "d07c06839e33047e2c894a6793248f3fb66c8129" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/d07c06839e33047e2c894a6793248f3fb66c8129", + "reference": "d07c06839e33047e2c894a6793248f3fb66c8129", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T14:29:57+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "e79d512848b75f92374e473f8e9ead4202c965c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/e79d512848b75f92374e473f8e9ead4202c965c5", + "reference": "e79d512848b75f92374e473f8e9ead4202c965c5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-26T10:31:34+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "symfony/flex", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/flex.git", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1", + "php": ">=8.1" + }, + "conflict": { + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" + }, + "require-dev": { + "composer/composer": "^2.1", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" + }, + "autoload": { + "psr-4": { + "Symfony\\Flex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T17:25:22+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/1c7b41364312c67376a560e35c7f7f0be0c1b4ea", + "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.4.1", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^8.1.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.33", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^3.7.1", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<8.1.2", + "symfony/form": "<7.4", + "symfony/json-streamer": "<7.4", + "symfony/mailer": "<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", + "symfony/mime": "<7.4.9|>=8.0,<8.0.9", + "symfony/security-csrf": "<7.4", + "symfony/serializer": "<7.4", + "symfony/translation": "<7.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/console": "^8.1.2", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/json-streamer": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4.10|^8.0.10", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/notifier": "^7.4|^8.0", + "symfony/object-mapper": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/scheduler": "^7.4|^8.0", + "symfony/security-bundle": "^7.4|^8.0", + "symfony/semaphore": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/type-info": "^7.4.1|^8.0.1", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T19:39:55+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:59:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T13:45:00+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:18:34+00:00" + }, + { + "name": "symfony/runtime", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.4.1" + }, + "conflict": { + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:35:25+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-13T16:11:28+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.9.0" +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..49d3fb6 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,5 @@ + ['all' => true], +]; diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000..6899b72 --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,19 @@ +framework: + cache: + # Unique name of your app: used to compute stable namespaces for cache keys. + #prefix_seed: your_vendor_name/app_name + + # The "app" cache stores to the filesystem by default. + # The data in this cache should persist between deploys. + # Other options include: + + # Redis + #app: cache.adapter.redis + #default_redis_provider: redis://localhost + + # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) + #app: cache.adapter.apcu + + # Namespaced pools use the above "app" backend by default + #pools: + #my.dedicated.cache: null diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..7e1ee1f --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,15 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Note that the session will be started ONLY if you read or write from it. + session: true + + #esi: true + #fragments: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..0f34f87 --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,10 @@ +framework: + router: + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..5ebcdb2 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|Param|null>|Param|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decorates_tag?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * decorates?: string, + * decorates_tag?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: array, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: Param|string|list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: Param|string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: false + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, // Deprecated: Setting the "framework.http_cache.terminate_on_cache_hit.terminate_on_cache_hit" configuration option is deprecated. It will be removed in version 9.0. + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: true|Param, // Deprecated: Setting the "framework.profiler.collect_serializer_data.collect_serializer_data" configuration option is deprecated. It will be removed in version 9.0. // Default: true + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|Param|string|list, + * events_to_dispatch?: null|list, + * places?: Param|string|list, + * }>, + * transitions?: list, + * to?: \BackedEnum|Param|string|list, + * weight?: int|Param, // Default: 1 + * metadata?: array, + * }>, + * metadata?: array, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: false + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: Param|string|list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: false + * paths?: Param|string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: false + * fallbacks?: Param|string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * static_method?: Param|string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * property_metadata_existence_check?: bool|Param, // When enabled, validateProperty() and validatePropertyValue() throw an exception if no metadata is found for the given property. // Default: false + * auto_mapping?: array, + * }>, + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: array, + * named_serializers?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: false + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: false + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: false + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null + * pools?: array, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, + * marshaller?: scalar|Param|null, // The marshaller service to use for this pool. + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: false + * }, + * lock?: Param|bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: false + * resources?: Param|string|array>, + * }, + * semaphore?: Param|bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: Param|string|array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: false + * routing?: array>, + * serializer?: array{ + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * retry_strategy?: Param|string|array{ + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: Param|int|string|list, + * default_bus?: scalar|Param|null, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: false + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: false + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400 + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: Param|int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|Param|null, // `true` to always return empty 200 responses, or the id of the service to use to generate mock responses - which should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * mock_response_factory?: scalar|Param|null, // `true` to always return empty 200 responses, `false` to disable mocking, or the id of the service to use to generate mock responses (invokable or iterable). + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400 + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: Param|int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|Param|null, + * recipients?: Param|string|list, + * allowed_recipients?: Param|string|list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: false + * limiters?: array, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * anchor_at?: scalar|Param|null, // Aligns the "fixed_window" policy to a calendar (e.g. "2024-01-05 00:00:00 UTC" combined with `interval: 1 month` resets the counter on the 5th of each month). UTC if not specified. // Default: null + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: false + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, + * uuid47_secret?: scalar|Param|null, // A high-entropy secret used by the "uuid47_transformer" service. Defaults to "kernel.secret". // Default: null + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: Param|string|list, + * drop_elements?: Param|string|list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: Param|string|list, + * allowed_link_hosts?: Param|null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: Param|string|list, + * allowed_media_hosts?: Param|null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: Param|string|list, + * without_attribute_sanitizers?: Param|string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * event_header_name?: scalar|Param|null, // Default: "Webhook-Event" + * id_header_name?: scalar|Param|null, // Default: "Webhook-Id" + * signature_header_name?: scalar|Param|null, // Default: "Webhook-Signature" + * signing_algorithm?: scalar|Param|null, // Default: "sha256" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * default_options?: array{ + * include_null_properties?: bool|Param, // Encode the properties with null value // Default: false + * ... + * }, + * }, + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 0000000..cef258c --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json + +# This file is the entry point to configure the routes of your app. +# Methods with the #[Route] attribute are automatically imported. +# See also https://symfony.com/doc/current/routing.html + +# To list all registered routes, run the following command: +# bin/console debug:router + +controllers: + resource: routing.controllers diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml new file mode 100644 index 0000000..bc1feac --- /dev/null +++ b/config/routes/framework.yaml @@ -0,0 +1,4 @@ +when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.php' + prefix: /_error diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..79b8ce2 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json + +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. +# See also https://symfony.com/doc/current/service_container/import.html + +# 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: + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + + # add more service definitions when explicit configuration is needed + # please note that last definitions always *replace* previous ones diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..c0037a8 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ + An array of allowed values for APP_ENV + */ + private function getAllowedEnvs(): array + { + return ['prod', 'dev', 'test']; + } +} diff --git a/symfony.lock b/symfony.lock new file mode 100644 index 0000000..f0ee42d --- /dev/null +++ b/symfony.lock @@ -0,0 +1,60 @@ +{ + "symfony/console": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/flex": { + "version": "2.11", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/framework-bundle": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "8.1", + "ref": "312027aea160796a50bf2d185503afdb5d71f570" + }, + "files": [ + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "public/index.php", + "src/Controller/.gitignore", + "src/Kernel.php", + ".editorconfig" + ] + }, + "symfony/routing": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" + }, + "files": [ + "config/packages/routing.yaml", + "config/routes.yaml" + ] + } +} From d97f3da5f18d7c040340808b7c8a61558d9a42e8 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Mon, 31 Aug 2026 10:21:44 +0200 Subject: [PATCH 03/59] =?UTF-8?q?Experiments=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ddev/config.yaml | 1 + .ddev/docker-compose.scorpio.yaml | 37 + .env | 4 + .env.test | 3 + .gitignore | 25 + .php-cs-fixer.dist.php | 20 + .twig-cs-fixer.dist.php | 16 + Notes.md | 9 - README.md | 153 +- Taskfile.yml | 178 + assets/app.js | 114 + assets/styles/app.css | 0 bin/phpunit | 4 + composer.json | 108 +- composer.lock | 7106 +++++++++++++++++++++++-- config/bundles.php | 5 + config/packages/asset_mapper.yaml | 11 + config/packages/debug.yaml | 5 + config/packages/framework.yaml | 8 + config/packages/property_info.yaml | 3 + config/packages/twig.yaml | 6 + config/packages/web_profiler.yaml | 11 + config/reference.php | 126 +- config/routes/web_profiler.yaml | 8 + importmap.php | 28 + phpstan.dist.neon | 20 + phpunit.dist.xml | 44 + src/Command/BrokerEntityDelete.php | 96 + src/Command/BrokerImportGeoJson.php | 101 + src/Command/ConvertPointCommand.php | 46 + src/Controller/DataController.php | 65 + src/Controller/DefaultController.php | 18 + src/GeometryHelper.php | 85 + src/GeometryHelper/Crs.php | 15 + symfony.lock | 134 + templates/base.html.twig | 39 + templates/default/hundeskov.html.twig | 113 + templates/default/index.html.twig | 63 + tests/GeometryHelperTest.php | 61 + tests/bootstrap.php | 13 + 40 files changed, 8382 insertions(+), 520 deletions(-) create mode 100644 .ddev/docker-compose.scorpio.yaml create mode 100644 .env.test create mode 100644 .php-cs-fixer.dist.php create mode 100644 .twig-cs-fixer.dist.php delete mode 100644 Notes.md create mode 100644 Taskfile.yml create mode 100644 assets/app.js create mode 100644 assets/styles/app.css create mode 100755 bin/phpunit create mode 100644 config/packages/asset_mapper.yaml create mode 100644 config/packages/debug.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/packages/web_profiler.yaml create mode 100644 config/routes/web_profiler.yaml create mode 100644 importmap.php create mode 100644 phpstan.dist.neon create mode 100644 phpunit.dist.xml create mode 100644 src/Command/BrokerEntityDelete.php create mode 100644 src/Command/BrokerImportGeoJson.php create mode 100644 src/Command/ConvertPointCommand.php create mode 100644 src/Controller/DataController.php create mode 100644 src/Controller/DefaultController.php create mode 100644 src/GeometryHelper.php create mode 100644 src/GeometryHelper/Crs.php create mode 100644 templates/base.html.twig create mode 100644 templates/default/hundeskov.html.twig create mode 100644 templates/default/index.html.twig create mode 100644 tests/GeometryHelperTest.php create mode 100644 tests/bootstrap.php diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 0594bc0..6ea79ab 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -14,6 +14,7 @@ composer_version: "2" web_environment: [] nodejs_version: "24" corepack_enable: false +disable_upload_dirs_warning: true # Key features of DDEV's config.yaml: diff --git a/.ddev/docker-compose.scorpio.yaml b/.ddev/docker-compose.scorpio.yaml new file mode 100644 index 0000000..e1f60a2 --- /dev/null +++ b/.ddev/docker-compose.scorpio.yaml @@ -0,0 +1,37 @@ +networks: + scorpio: + +# https://docs.ddev.com/en/stable/users/extend/custom-docker-services/ +services: + scorpio: + container_name: "ddev-${DDEV_SITENAME}-scorpio" + hostname: scorpio.local + image: scorpiobroker/all-in-one-runner:java-latest + restart: "no" + ports: + - "9090" + environment: + - VIRTUAL_HOST=${DDEV_HOSTNAME} + - HTTP_EXPOSE=9090:9999 + - DBHOST=scorpio-db + depends_on: + - "scorpio-db" + networks: + - default + - scorpio + platform: "linux/amd64" + + scorpio-db: + container_name: "ddev-${DDEV_SITENAME}-scorpio-db" + image: postgis/postgis + environment: + - POSTGRES_USER=ngb + - POSTGRES_PASSWORD=ngb + - POSTGRES_DB=ngb + networks: + - scorpio + volumes: + - ./data/scorpio-db:/var/lib/postgresql/data + platform: "linux/amd64" + # https://stackoverflow.com/a/58806511 + command: ["postgres", "-c", "log_statement=all"] diff --git a/.env b/.env index 9e19457..b9dbe2b 100644 --- a/.env +++ b/.env @@ -25,3 +25,7 @@ APP_SHARE_DIR=var/share # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=http://localhost ###< symfony/routing ### + +###> app ### +APP_BROKER_BASE_URI=http://scorpio.local:9090/ +###< app ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..64bd111 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore index a67f91e..fbd3470 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.ddev/data +.idea ###> symfony/framework-bundle ### /.env.local @@ -8,3 +10,26 @@ /var/ /vendor/ ###< symfony/framework-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + +###> vincentlanglet/twig-cs-fixer ### +/.twig-cs-fixer.cache +###< vincentlanglet/twig-cs-fixer ### + +###> phpstan/phpstan ### +phpstan.neon +###< phpstan/phpstan ### + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..c23b927 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,20 @@ +in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder); + +$config->setRules([ + '@Symfony' => true, +]); + +return $config; diff --git a/.twig-cs-fixer.dist.php b/.twig-cs-fixer.dist.php new file mode 100644 index 0000000..8242555 --- /dev/null +++ b/.twig-cs-fixer.dist.php @@ -0,0 +1,16 @@ +in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new TwigCsFixer\Config\Config(); +$config->setFinder($finder); + +return $config; diff --git a/Notes.md b/Notes.md deleted file mode 100644 index bbd29b1..0000000 --- a/Notes.md +++ /dev/null @@ -1,9 +0,0 @@ -# Notes - -## GeoJSON - -* [New Features and Changes in NGSI-LD v1.3.1](https://docbox.etsi.org/ISG/CIM/Open/NGSI-LD_API_v1.3.1-New_Features_and_Changes.pdf) -* https://github.com/ScorpioBroker/ScorpioBroker/issues/128 - * https://github.com/ScorpioBroker/ScorpioBroker/issues/576 - -Is a `location` property the way to store location data on an entity? diff --git a/README.md b/README.md index fa913b3..8e132d2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,157 @@ # Enter -We use [DDEV](https://ddev.com/) for development: +We use [DDEV](https://ddev.com/) and [Task](https://taskfile.dev/) for development: ``` shell -ddev start +task site:install + +``` shell +task site:update ddev launch ``` + +## Broker + +A [Scorpio Broker](https://scorpio.readthedocs.io/) is part of the development setup. + +``` shell +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/types | jq" +``` + +Load some example data: + +``` shell name=import-toilet +ddev console app:broker:entity:delete toilet +ddev console app:broker:import:geojson toilet 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter' +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=toilet" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location"])))' +``` + +``` shell name=import-handicapparkering +ddev console app:broker:entity:delete handicapparkering +ddev console app:broker:import:geojson handicapparkering 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=handicapparkering" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location"])))' +``` + +``` shell name=import-hundeskov +ddev console app:broker:entity:delete hundeskov +ddev console app:broker:import:geojson hundeskov 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=hundeskove_friluftsliv_aarhus' +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=hundeskov" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location", "geometry"])))' +``` + + +### Broker API request examples + + + +``` shell name=scorpio-entity-create substitutions="{«entity-type»: Room, «entity-id»: 'house2:smartrooms:room1'}" +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --header 'content-type: application/json' --data @-" <<'JSON' +{ + "type": "«entity-type»", + "id": "«entity-id»" +} +JSON + + +# EPSG:4326?! +ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities/«entity-id»/attrs --header 'content-type: application/json' --data @-" <<'JSON' +{ + "location": { + "type": "geo:json", + "value": { + "type": "Point", + "coordinates": [ + 10.15711687080293, 56.126271111641266 + ] + } + } +} +JSON + + +# Get the entities +ddev exec "curl --silent --header 'accept: application/ld+json' http://scorpio.local:9090/ngsi-ld/v1/entities?type=«entity-type» | jq" + +# Get the entities as GeoJSON +ddev exec "curl --silent --header 'accept: application/geo+json' http://scorpio.local:9090/ngsi-ld/v1/entities?type=«entity-type» | jq" +``` + +> [!CAUTION] +> Excuse me what?! +> +> ``` shell +> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance%3D%3D2000&geometry=Point&coordinates=%5B8,40%5D'" +> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance==2000&geometry=Point&coordinates=%5B8,40%5D'" +> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance==2000&geometry=Point&coordinates=[10,56]'" +> > ``` + +## GeoJSON + +* "[Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1)": `(longitude, latitude)` + * "[Coordinate Reference System](https://datatracker.ietf.org/doc/html/rfc7946#section-4)" + * But … + + : + + ```json + { + "type": "FeatureCollection", + "crs": { + "type": "name", + "properties": { + "name": "EPSG:25832" + } + }, + "bbox": [563262.0903961, 6209995.05346621, 579587.800694027, 6231954.36230685], + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [576933.018139537, 6218035.22691216] + ] + }, + "properties": { + … + ``` + +* + + +--- + +* +* +* +* + +Must `location` be a `Point` in ngsi-ld? + +``` shell +ddev exec --service scorpio-db "psql ngb ngb" +``` + +``` sql +2026-08-30 10:26:19.789 UTC [41] LOG: execute 0000000: WITH D0 AS (SELECT ID, ENTITY, TRUE as PARENT FROM ENTITY WHERE ST_DWithin( location::geography, ST_SetSRID(ST_GeomFromGeoJSON('{"type": "Point", "coordinates": [10.236808047161853,56.101226991176155] }'), 4326)::geography, 2000.0) ORDER BY createdAt limit $1 offset $2) SELECT ID, ENTITY, PARENT FROM D0 +``` + + + +``` shell +ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT id , e_types, location FROM entity;" +``` + +``` shell name=hmm +ddev console app:import:geojson toilet 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter' \ + && ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT id , e_types, ST_AsText(location) AS location FROM entity WHERE id = 'toilet:0000';" \ + && ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT temporalentity_id, ST_AsText(location) AS location, ST_asText(geovalue) AS geovalue, createdat FROM temporalentityattrinstance WHERE temporalentity_id = 'toilet:0000' ORDER BY createdat DESC LIMIT 10;" +``` + + + +* [Public toilets in Aarhus Municipality](https://www.opendata.dk/city-of-aarhus/toiletter-i-aarhus-kommune) + * [Offentlige toiletter](http://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter): +* [Parking in Aarhus Municipality – Zones, Permits and Spaces](https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune) + * [Handicapparkering](https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap): + +* diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..400d1f1 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,178 @@ +version: '3' + +# https://taskfile.dev/usage/#env-files +dotenv: ['.env.local', '.env'] + +tasks: + site:install: + desc: Install site from scratch + prompt: Really (re)install site? + cmds: + - ddev delete --omit-snapshot --yes + - rm -fr .ddev/data/scorpio-db/ + - task: site:update + + site:update: + desc: Update site + cmds: + - ddev restart + - ddev composer install + # - ddev console doctrine:migrations:migrate --no-interaction + - ddev console cache:clear + + test: + desc: Run all tests + cmds: + - task: test:application + - task: test:integration + + test:integration: &test_task + env: + APP_ENV: test + desc: 'Run all {{.TASK | replace "test:" "" }} tests' + cmd: ddev php bin/phpunit tests/{{.TASK | replace "test:" "" | title }} + + test:application: *test_task + + coding-standards:apply: + desc: 'Apply coding standards' + cmds: + - task: coding-standards:assets:apply + - task: coding-standards:composer:apply + - task: coding-standards:markdown:apply + - task: coding-standards:php:apply + - task: coding-standards:twig:apply + - task: coding-standards:yaml:apply + + coding-standards:check: + desc: 'Apply coding standards' + cmds: + - task: coding-standards:assets:check + - task: coding-standards:composer:check + - task: coding-standards:markdown:check + - task: coding-standards:php:check + - task: coding-standards:twig:check + - task: coding-standards:yaml:check + + coding-standards:assets:apply: + desc: 'Apply coding standards for assets' + cmd: + task: compose + vars: + TASK_ARGS: run --rm prettier 'assets/' --write + + coding-standards:assets:check: + desc: 'Apply and check coding standards for assets' + cmds: + - task: coding-standards:assets:apply + - task: compose + vars: + TASK_ARGS: run --rm prettier 'assets/' --check + + coding-standards:composer:apply: + desc: 'Apply coding standards for Composer' + cmd: + task: composer + vars: + TASK_ARGS: normalize + + coding-standards:composer:check: + desc: 'Apply and check coding standards for Composer' + cmds: + - task: coding-standards:composer:apply + - task: composer + vars: + TASK_ARGS: audit + - task: composer + vars: + TASK_ARGS: normalize --dry-run + - task: composer + vars: + TASK_ARGS: validate --strict + + coding-standards:markdown:apply: + desc: 'Apply coding standards for Markdown' + cmds: + - task: compose + vars: + TASK_ARGS: run --rm markdownlint '**/*.md' --fix + + coding-standards:markdown:check: + desc: 'Apply and check coding standards for Markdown' + cmds: + - task: coding-standards:markdown:apply + - task: compose + vars: + TASK_ARGS: run --rm markdownlint '**/*.md' + + coding-standards:php:apply: + desc: 'Apply coding standards for PHP' + cmds: + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/rector + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/php-cs-fixer fix + + coding-standards:php:check: + desc: 'Apply and check coding standards for PHP' + cmds: + - task: coding-standards:php:apply + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/rector --dry-run + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/php-cs-fixer check + + coding-standards:twig:apply: + desc: 'Apply coding standards for Twig' + cmds: + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/twig-cs-fixer fix + + coding-standards:twig:check: + desc: 'Apply and check coding standards for Twig' + cmds: + - task: coding-standards:twig:apply + - task: compose + vars: + TASK_ARGS: exec phpfpm vendor/bin/twig-cs-fixer check + + coding-standards:yaml:apply: + desc: 'Apply coding standards for YAML' + cmds: + - task: compose + vars: + TASK_ARGS: run --rm prettier --parser yaml phpstan*.neon '**/*.{yml,yaml}' --write + + coding-standards:yaml:check: + desc: 'Apply and check coding standards for YAML' + cmds: + - task: coding-standards:yaml:apply + - task: compose + vars: + TASK_ARGS: run --rm prettier --parser yaml phpstan*.neon '**/*.{yml,yaml}' --check + + code-analysis: + desc: 'Run code analysis' + cmds: + - task: code-analysis:phpstan + - task: code-analysis:mago + + code-analysis:phpstan: + desc: 'Run PHPStan' + cmds: + - task: compose + vars: + TASK_ARGS: run --rm --no-deps phpfpm composer install --no-scripts + - task: compose + vars: + TASK_ARGS: run --rm --no-deps phpfpm vendor/bin/phpstan + + default: + cmds: + - task --list-all + silent: true diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..d7879d7 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,114 @@ +/* + * Welcome to your app's main JavaScript file! + * + * This file will be included onto the page via the importmap() Twig function, + * which should already be in your base.html.twig. + */ +import './styles/app.css'; +import WidgetAPI from 'septima-widget' + +// const widget = new WidgetAPI('#map', { +// map: { +// debug: true, +// view: { +// zoomLevel: 4, +// "x": 572929.1565135007, +// "y": 6225655.880819891, +// // x: 724413, +// // y: 6175985, +// }, +// layer: [ +// { +// namedlayer: "#septima_standard" +// }, + +// // { +// // "features_host": "https://widget.cdn.septima.dk/demodata/dataapi1.json", +// // "visible": true, +// // "features": true, +// // "features_dataType": "json", +// // "type": "rawjson", +// // "features_template_rows": "0.produktionsenhed", +// // "features_template": "{ 'type': 'Feature', 'properties': { 'id': <%= cvrnummer %>, 'navn': '<%= navn %>'}, 'geometry': <%= _formatJSON(geometri) %>}", +// // "features_style": [ +// // { +// // "namedstyle": "#004", +// // "symbol": "circle", +// // "fillcolor": "rgba(255,255,255,1)", +// // "strokecolor": "<%= _getRandomColor(1, id) %>", +// // "strokewidth": 2, +// // "radius": 14 +// // }, +// // { +// // "namedstyle": "#004", +// // "fillcolor": "<%= _getRandomColor(1, id) %>", +// // "yOffset": 15, +// // "scale": 0.5, +// // "svg": "" +// // } +// // ] +// // }, + +// { +// "name": "Data", +// "selectable": true, +// "features": true, +// // # https://widget.cdn.septima.dk/latest/components/helpers/layer.html#type +// "type": "geojson", +// // # https://datatracker.ietf.org/doc/html/rfc7946#section-4 +// // # https://geoinfospot.com/what-is-wgs84-the-coordinate-system/ +// "srs": "EPSG:4326", +// "visible": true, + +// // # "features_host": "https://widget.cdn.septima.dk/demodata/skoler.geojson", + +// // # "features_host": "/data/ngsi-ld/v1/entities.geojson?type=Room", + +// // # "features_host": "/data/ngsi-ld/v1/entities.geojson?type=toilet", + +// // # "features_host": "/data/ngsi-ld/v1/entities.geojson?type=handicapparkering", + +// "features_host": "/data/ngsi-ld/v1/entities.geojson?type=hundeskov&limit=1", +// // # https://widget.cdn.septima.dk/latest/components/helpers/layer.html#features-template +// // # https://widget.cdn.septima.dk/latest/components/helpers/template.html#formatjson-data + +// // https://widget.cdn.septima.dk/latest/examples/data23.html#data-fra-json +// "features_host": "/data/ngsi-ld/v1/entities?type=hundeskov&limit=1", +// "features_dataType": "json", +// "type": "rawjson", +// "features_template": "{ 'type': 'Feature', 'properties': {'id': <%= cvrnummer %>, 'navn': '<%= navn %>'}, 'geometry': <%= _formatJSON(location) %>}", +// "features_template": "{ 'type': 'Feature', 'properties': { 'id': <%= cvrnummer %>, 'navn': '<%= navn %>'}, 'geometry': <%= _formatJSON(geometri) %>}", + +// // # "features_template": "{ 'type': 'Feature', 'properties': {\"navn\": \"Hmm …\"}, 'geometry': <%= _formatJSON('{ \"type\": \"Point\", \"coordinates\": [ 10.179484273715866, 56.18474125523462 ] }') %>}", + +// // "features_dataType": "json", +// // # "features_type": "Point", + +// "features_style": [ +// { +// "symbol": "circle", +// "symbol_selected": "circle", +// "radius": 15, +// "radius_selected": 20, +// "fillcolor": "rgba(1, 119, 134, 1)", +// "fillcolor_selected": "rgba(1, 119, 134, 1)" +// }, +// { +// "icon": "school", +// "icon_selected": "school", +// "scale": 0.8, +// "scale_selected": 1.2, +// "fillcolor": "rgba(255, 255, 255, 1)", +// "fillcolor_selected": "rgba(255, 255, 255, 1)", +// "xOffset": 0, +// "yOffset": 1, +// "yOffset_selected": 2 +// } +// ], + +// }, +// ], +// } +// }) + +// console.debug({widget}) diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..e69de29 diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..ac5eef1 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,4 @@ +#!/usr/bin/env php +=8.4", "ext-ctype": "*", "ext-iconv": "*", - "symfony/console": "8.1.*", - "symfony/dotenv": "8.1.*", + "itamair/geophp": "^1.12", + "phpdocumentor/reflection-docblock": "^6.0", + "phpstan/phpdoc-parser": "^2.3", + "proj4php/proj4php": "^2.0", + "symfony/asset": "~8.1.0", + "symfony/asset-mapper": "~8.1.0", + "symfony/console": "~8.1.0", + "symfony/dotenv": "~8.1.0", "symfony/flex": "^2", - "symfony/framework-bundle": "8.1.*", - "symfony/runtime": "8.1.*", - "symfony/yaml": "8.1.*" + "symfony/framework-bundle": "~8.1.0", + "symfony/http-client": "~8.1.0", + "symfony/property-access": "~8.1.0", + "symfony/property-info": "~8.1.0", + "symfony/runtime": "~8.1.0", + "symfony/serializer": "~8.1.0", + "symfony/twig-bundle": "~8.1.0", + "symfony/yaml": "~8.1.0", + "twig/extra-bundle": "^2.12 || ^3.0", + "twig/twig": "^2.12 || ^3.0" }, "require-dev": { - }, - "config": { - "allow-plugins": { - "php-http/discovery": true, - "symfony/flex": true, - "symfony/runtime": true - }, - "bump-after-update": true, - "sort-packages": true - }, - "autoload": { - "psr-4": { - "App\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "App\\Tests\\": "tests/" - } + "ergebnis/composer-normalize": "^2.52", + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.3", + "rector/rector": "^2.6", + "symfony/browser-kit": "~8.1.0", + "symfony/css-selector": "~8.1.0", + "symfony/debug-bundle": "8.1.*", + "symfony/maker-bundle": "^1.67", + "symfony/stopwatch": "~8.1.0", + "symfony/web-profiler-bundle": "~8.1.0", + "vincentlanglet/twig-cs-fixer": "^4.1" }, "replace": { "symfony/polyfill-ctype": "*", @@ -47,25 +51,49 @@ "symfony/polyfill-php83": "*", "symfony/polyfill-php84": "*" }, - "scripts": { - "auto-scripts": { - "cache:clear": "symfony-cmd", - "assets:install %PUBLIC_DIR%": "symfony-cmd" - }, - "post-install-cmd": [ - "@auto-scripts" - ], - "post-update-cmd": [ - "@auto-scripts" - ] - }, "conflict": { "symfony/symfony": "*" }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, "extra": { "symfony": { - "allow-contrib": false, + "allow-contrib": true, + "docker": false, "require": "8.1.*" } + }, + "scripts": { + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ], + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" + } } } diff --git a/composer.lock b/composer.lock index 2ec33ce..782be9a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,467 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f2d2bb175ffe409da49e7034d1f3a93", + "content-hash": "5bd73ae1836b8ed57dcd7a8086881f80", "packages": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "itamair/geophp", + "version": "1.12", + "source": { + "type": "git", + "url": "https://github.com/itamair/geoPHP.git", + "reference": "e0f87ad0e45f3898892a8f3bc02e8717c01554fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/itamair/geoPHP/zipball/e0f87ad0e45f3898892a8f3bc02e8717c01554fb", + "reference": "e0f87ad0e45f3898892a8f3bc02e8717c01554fb", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "4.1.* || 9.5.*" + }, + "type": "library", + "autoload": { + "classmap": [ + "geoPHP.inc" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "Italo Mairo", + "homepage": "https://www.linkedin.com/in/italomairo/", + "role": "Maintanier of this Library Repo" + }, + { + "name": "Patrick Hayes", + "homepage": "https://www.linkedin.com/in/patrickdhayes/", + "role": "Maintanier of original Repositary/Library (https://github.com/phayes/geoPHP)" + } + ], + "description": "GeoPHP is a open-source native PHP library for doing geometry operations. It is written entirely in PHP and can therefore run on shared hosts. It can read and write a wide variety of formats: WKT (including EWKT), WKB (including EWKB), GeoJSON, KML, GPX, GeoRSS). It works with all Simple-Feature geometries (Point, LineString, Polygon, GeometryCollection etc.) and can be used to get centroids, bounding-boxes, area, and a wide variety of other useful information.", + "homepage": "https://github.com/itamair/geoPHP", + "support": { + "issues": "https://github.com/itamair/geoPHP/issues", + "source": "https://github.com/itamair/geoPHP/tree/1.12" + }, + "time": "2026-04-02T10:02:52+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "proj4php/proj4php", + "version": "v2.0.19", + "source": { + "type": "git", + "url": "https://github.com/proj4php/proj4php.git", + "reference": "52436c32e43efea3251d0c0d20f5fe387de46f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/proj4php/proj4php/zipball/52436c32e43efea3251d0c0d20f5fe387de46f6a", + "reference": "52436c32e43efea3251d0c0d20f5fe387de46f6a", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "9.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "proj4php\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "julien2512", + "email": "moquet.julien@gmail.com", + "homepage": "https://github.com/julien2512", + "role": "Developer" + }, + { + "name": "Jason Judge", + "email": "jason.judge@academe.co.uk", + "homepage": "http://academe.co.uk", + "role": "Developer" + }, + { + "name": "Nick Blackwell", + "email": "nickblackwell82@gmail.com", + "homepage": "https://people.ok.ubc.ca/nblackwe", + "role": "Developer" + } + ], + "description": "A PHP-Class for geographic coordinates transformation using proj4 definitions, thanks to a translation from Proj4JS", + "homepage": "https://github.com/proj4php/proj4php", + "keywords": [ + "coordinates", + "geographic", + "proj4", + "proj4js" + ], + "support": { + "issues": "https://github.com/proj4php/proj4php/issues", + "source": "https://github.com/proj4php/proj4php/tree/v2.0.19" + }, + "time": "2025-09-11T16:36:16+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -208,6 +667,157 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "symfony/asset", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/asset-mapper", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset-mapper.git", + "reference": "b13fd8210675bafac7e782569d0cafac860f2e8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/b13fd8210675bafac7e782569d0cafac860f2e8f", + "reference": "b13fd8210675bafac7e782569d0cafac860f2e8f", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "php": ">=8.4.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/event-dispatcher-contracts": "^3.0", + "symfony/finder": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\AssetMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset-mapper/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-14T13:31:31+00:00" + }, { "name": "symfony/cache", "version": "v8.1.5", @@ -1395,42 +2005,55 @@ "time": "2026-08-21T19:39:55+00:00" }, { - "name": "symfony/http-foundation", + "name": "symfony/http-client", "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" + "url": "https://github.com/symfony/http-client.git", + "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "url": "https://api.github.com/repos/symfony/http-client/zipball/9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", + "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.1" + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "doctrine/dbal": "<4.3" + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" }, "require-dev": { - "doctrine/dbal": "^4.3", - "predis/predis": "^1.1|^2.0", + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10|^8.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" + "Symfony\\Component\\HttpClient\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1442,18 +2065,21 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Defines an object-oriented layer for the HTTP specification", + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", "homepage": "https://symfony.com", + "keywords": [ + "http" + ], "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + "source": "https://github.com/symfony/http-client/tree/v8.1.5" }, "funding": [ { @@ -1473,77 +2099,41 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:59:12+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/http-kernel", - "version": "v8.1.5", + "name": "symfony/http-client-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/http-kernel.git", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/flex": "<2.10", - "symfony/http-client-contracts": "<2.5", - "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", - "symfony/translation-contracts": "<2.5", - "symfony/var-dumper": "<8.1", - "symfony/web-profiler-bundle": "<8.1", - "twig/twig": "<3.21" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" - }, - "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/routing": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^8.1", - "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21|^4.0" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\HttpKernel\\": "" + "Symfony\\Contracts\\HttpClient\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1552,18 +2142,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a structured process for converting a Request into a Response", + "description": "Generic abstractions related to HTTP clients", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -1583,20 +2181,211 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:45:00+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/polyfill-deepclone", - "version": "v1.42.0", + "name": "symfony/http-foundation", + "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" + "url": "https://github.com/symfony/http-foundation.git", + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", - "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:59:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T13:45:00+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", "shasum": "" }, "require": { @@ -1666,217 +2455,5558 @@ "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:40:56+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/b335f8e7fb1440ed3448fb33340efc6127678e53", + "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:18:34+00:00" + }, + { + "name": "symfony/runtime", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.4.1" + }, + "conflict": { + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/9c064d505c661e3e17aa8999870f4fb0ed05e024", + "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:06:25+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:35:25+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "c721b1c98de3125ca195e35d52aaec5c97aa9c5f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c721b1c98de3125ca195e35d52aaec5c97aa9c5f", + "reference": "c721b1c98de3125ca195e35d52aaec5c97aa9c5f", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.25|^4.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/form": "<7.4.4|>8.0,<8.0.4", + "symfony/mime": "<7.4.9|>8.0,<8.0.9" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:06:25+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "a70b67c2cd990d05e544ea5fab361aa0e69fab3a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/a70b67c2cd990d05e544ea5fab361aa0e69fab3a", + "reference": "a70b67c2cd990d05e544ea5fab361aa0e69fab3a", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "3783365b58972f4779254d98372af80fbf15e170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:55+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-13T16:11:28+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "twig/extra-bundle", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.2|^4.0" + }, + "require-dev": { + "league/commonmark": "^2.7", + "symfony/phpunit-bridge": "^6.4|^7.0", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-02-07T08:07:38+00:00" + }, + { + "name": "twig/twig", + "version": "v3.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-07-03T20:44:34+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "ergebnis/composer-normalize", + "version": "2.52.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/composer-normalize.git", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "ergebnis/json": "^1.4.0", + "ergebnis/json-normalizer": "^4.9.0", + "ergebnis/json-printer": "^3.7.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "localheinz/diff": "^1.3.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "composer/composer": "^2.9.8", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.62.1", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.4.3", + "symfony/filesystem": "^5.4.41" + }, + "type": "composer-plugin", + "extra": { + "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", + "branch-alias": { + "dev-main": "2.52-dev" + }, + "plugin-optional": true, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Composer\\Normalize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a composer plugin for normalizing composer.json.", + "homepage": "https://github.com/ergebnis/composer-normalize", + "keywords": [ + "composer", + "normalize", + "normalizer", + "plugin" + ], + "support": { + "issues": "https://github.com/ergebnis/composer-normalize/issues", + "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/composer-normalize" + }, + "time": "2026-05-15T15:39:24+00:00" + }, + { + "name": "ergebnis/json", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json.git", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^9.6.24", + "rector/rector": "^2.1.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a Json value object for representing a valid JSON string.", + "homepage": "https://github.com/ergebnis/json", + "keywords": [ + "json" + ], + "support": { + "issues": "https://github.com/ergebnis/json/issues", + "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json" + }, + "time": "2025-09-06T09:08:45+00:00" + }, + { + "name": "ergebnis/json-normalizer", + "version": "4.10.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-normalizer.git", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ergebnis/json-printer": "^3.5.0", + "ergebnis/json-schema-validator": "^4.2.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "composer/semver": "^3.4.3", + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^1.2.10" + }, + "suggest": { + "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.11-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Normalizer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", + "homepage": "https://github.com/ergebnis/json-normalizer", + "keywords": [ + "json", + "normalizer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-normalizer/issues", + "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-normalizer" + }, + "time": "2025-09-06T09:18:13+00:00" + }, + { + "name": "ergebnis/json-pointer", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-pointer.git", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.8-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Pointer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides an abstraction of a JSON pointer.", + "homepage": "https://github.com/ergebnis/json-pointer", + "keywords": [ + "RFC6901", + "json", + "pointer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-pointer/issues", + "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-pointer" + }, + "time": "2026-04-07T14:52:13+00:00" + }, + { + "name": "ergebnis/json-printer", + "version": "3.8.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-printer.git", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.1", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.21", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Printer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON printer, allowing for flexible indentation.", + "homepage": "https://github.com/ergebnis/json-printer", + "keywords": [ + "formatter", + "json", + "printer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-printer/issues", + "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-printer" + }, + "time": "2025-09-06T09:59:26+00:00" + }, + { + "name": "ergebnis/json-schema-validator", + "version": "4.5.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-schema-validator.git", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/b739527a480a9e3651360ad351ea77e7e9019df2", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.20", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.6-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\SchemaValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", + "homepage": "https://github.com/ergebnis/json-schema-validator", + "keywords": [ + "json", + "schema", + "validator" + ], + "support": { + "issues": "https://github.com/ergebnis/json-schema-validator/issues", + "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-schema-validator" + }, + "time": "2025-09-06T11:37:35+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.23", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "b2932a5af3157a0c28324b1efe5e3b556dee09c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b2932a5af3157a0c28324b1efe5e3b556dee09c4", + "reference": "b2932a5af3157a0c28324b1efe5e3b556dee09c4", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.23" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-08-26T19:54:54+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.11.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "7e420a943a6fbc95e60e3cf67acfbee85b3b4da7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7e420a943a6fbc95e60e3cf67acfbee85b3b4da7", + "reference": "7e420a943a6fbc95e60e3cf67acfbee85b3b4da7", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.11.0" + }, + "time": "2026-08-21T10:30:42+00:00" + }, + { + "name": "localheinz/diff", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/localheinz/diff.git", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", + "shasum": "" + }, + "require": { + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5.0 || ^8.5.23", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Fork of sebastian/diff for use with ergebnis/composer-normalize", + "homepage": "https://github.com/localheinz/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/localheinz/diff/issues", + "source": "https://github.com/localheinz/diff/tree/1.3.0" + }, + "time": "2025-08-30T09:44:18+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.10", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/36d1509c998b0602811824143526b4a9ee2774e7", + "reference": "36d1509c998b0602811824143526b4a9ee2774e7", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-30T12:46:16+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "6ce313bb110384148d1dc7695a99175f59529069" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6ce313bb110384148d1dc7695a99175f59529069", + "reference": "6ce313bb110384148d1dc7695a99175f59529069", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.2", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-08-16T05:23:47+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:47:43+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "22104a5ceb8d642e6b30ab00211d53d88e2db368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/22104a5ceb8d642e6b30ab00211d53d88e2db368", + "reference": "22104a5ceb8d642e6b30ab00211d53d88e2db368", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.3.1", + "phpunit/php-file-iterator": "^7.0.2", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.3-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.2" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-27T08:40:49+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "rector/rector", + "version": "2.6.5", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "f8686605798c755456327a3597201c3b5be76294" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f8686605798c755456327a3597201c3b5be76294", + "reference": "f8686605798c755456327a3597201c3b5be76294", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.10" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.6.5" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-08-30T22:02:04+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-08-01T04:27:14+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.4.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-08-07T07:23:13+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:38:55+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-08-07T07:22:06+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-08-13T07:05:05+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-08-13T06:34:36+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-03T05:58:12+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "bd1df467864cb95140414059a535b2d906173fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-08-10T08:00:57+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=8.4" }, "type": "library", "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "branch-alias": { + "dev-main": "7.0-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://github.com/fabpot", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=7.2" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, "classmap": [ - "Resources/stubs" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", + "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "static analysis" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/staabm", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/browser-kit", + "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", + "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" + "php": ">=8.4.1", + "symfony/dom-crawler": "^7.4|^8.0" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1884,25 +8014,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/browser-kit/tree/v8.1.5" }, "funding": [ { @@ -1922,41 +8045,32 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "name": "symfony/css-selector", + "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "url": "https://github.com/symfony/css-selector.git", + "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/a291fb5adb65f52a4bb315db2d803698315dc64d", + "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Component\\CssSelector\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1965,24 +8079,22 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.5" }, "funding": [ { @@ -2002,38 +8114,39 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/routing", - "version": "v8.1.5", + "name": "symfony/debug-bundle", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/2da1f202b38f646dbee032529cfd8e727cd12cd1", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1", "shasum": "" }, "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "require-dev": { - "psr/log": "^1|^2|^3", "symfony/config": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, - "type": "library", + "require-dev": { + "symfony/web-profiler-bundle": "^7.4|^8.0" + }, + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Bundle\\DebugBundle\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2053,16 +8166,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", - "keywords": [ - "router", - "routing", - "uri", - "url" - ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.5" + "source": "https://github.com/symfony/debug-bundle/tree/v8.1.0" }, "funding": [ { @@ -2082,45 +8189,34 @@ "type": "tidelift" } ], - "time": "2026-08-17T13:18:34+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/runtime", - "version": "v8.1.0", + "name": "symfony/dom-crawler", + "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/runtime.git", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d", + "reference": "94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0|^2.0", - "php": ">=8.4.1" - }, - "conflict": { - "symfony/error-handler": "<7.4" - }, - "require-dev": { - "composer/composer": "^2.6", - "symfony/console": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/dotenv": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" }, - "type": "composer-plugin", - "extra": { - "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Runtime\\": "", - "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + "Symfony\\Component\\DomCrawler\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2132,21 +8228,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Enables decoupling PHP applications from global state", + "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", - "keywords": [ - "runtime" - ], "support": { - "source": "https://github.com/symfony/runtime/tree/v8.1.0" + "source": "https://github.com/symfony/dom-crawler/tree/v8.1.5" }, "funding": [ { @@ -2166,74 +8259,86 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.7.1", + "name": "symfony/maker-bundle", + "version": "v1.67.0", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", "shasum": "" }, "require": { + "composer-runtime-api": "^2.1", + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" }, "conflict": { - "ext-psr": "<1.1|>=2" + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" }, - "type": "library", + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.15|^3", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "1.x-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] + "Symfony\\Bundle\\MakerBundle\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" }, "funding": [ { @@ -2253,46 +8358,30 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-03-18T13:39:06+00:00" }, { - "name": "symfony/string", - "version": "v8.1.2", + "name": "symfony/options-resolver", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" - }, - "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\OptionsResolver\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2304,26 +8393,23 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "description": "Provides an improved replacement for the array_replace PHP function", "homepage": "https://symfony.com", "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" + "config", + "configuration", + "options" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -2343,47 +8429,29 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/var-dumper", + "name": "symfony/process", "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a" + "url": "https://github.com/symfony/process.git", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/61743d9bc7ab23b194527ca1be2fafd7dc93b74a", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "symfony/console": "<7.4", - "symfony/error-handler": "<7.4" - }, - "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "twig/twig": "^3.12|^4.0" + "php": ">=8.4.1" }, - "bin": [ - "Resources/bin/var-dump-server" - ], "type": "library", "autoload": { - "files": [ - "Resources/functions/dump.php" - ], "psr-4": { - "Symfony\\Component\\VarDumper\\": "" + "Symfony\\Component\\Process\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2395,22 +8463,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "debug", - "dump" - ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.5" + "source": "https://github.com/symfony/process/tree/v8.1.5" }, "funding": [ { @@ -2430,36 +8494,30 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/var-exporter", - "version": "v8.1.5", + "name": "symfony/stopwatch", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/var-exporter.git", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" + "url": "https://github.com/symfony/stopwatch.git", + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.40" - }, - "require-dev": { - "symfony/property-access": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/service-contracts": "^2.5|^3" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\VarExporter\\": "" + "Symfony\\Component\\Stopwatch\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2471,29 +8529,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "description": "Provides a way to profile code", "homepage": "https://symfony.com", - "keywords": [ - "clone", - "construct", - "deep-clone", - "export", - "hydrate", - "instantiate", - "lazy-loading", - "proxy", - "serialize" - ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -2513,40 +8560,46 @@ "type": "tidelift" } ], - "time": "2026-08-13T16:11:28+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/yaml", + "name": "symfony/web-profiler-bundle", "version": "v8.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "4ae952afc65cb63fc74067633466bd8295cdcebc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4ae952afc65cb63fc74067633466bd8295cdcebc", + "reference": "4ae952afc65cb63fc74067633466bd8295cdcebc", "shasum": "" }, "require": { + "composer-runtime-api": ">=2.1", "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8" + "symfony/config": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/routing": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0" }, "conflict": { - "symfony/console": "<7.4" + "symfony/serializer": "<7.4", + "symfony/workflow": "<7.4" }, "require-dev": { + "symfony/browser-kit": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", - "yaml/yaml-test-suite": "*" + "symfony/css-selector": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\Yaml\\": "" + "Symfony\\Bundle\\WebProfilerBundle\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2566,10 +8619,13 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Loads and dumps YAML files", + "description": "Provides a development tool that gives detailed information about the execution of any request", "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.5" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.5" }, "funding": [ { @@ -2589,13 +8645,139 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-22T09:06:25+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "vincentlanglet/twig-cs-fixer", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", + "reference": "73f6474720fce9a6709e7b6cdd26574efe36d09b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/73f6474720fce9a6709e7b6cdd26574efe36d09b", + "reference": "73f6474720fce9a6709e7b6cdd26574efe36d09b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "ext-ctype": "*", + "php": ">=8.1", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", + "twig/twig": "^3.15", + "webmozart/assert": "^1.10 || ^2.0" + }, + "require-dev": { + "composer/semver": "^3.2.0", + "dereuromark/composer-prefer-lowest": "^0.1.10", + "ergebnis/composer-normalize": "^2.29", + "friendsofphp/php-cs-fixer": "^3.13.0", + "infection/infection": "^0.26.16 || ^0.32.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^9.5.26 || ^11.5.18 || ^12.1.3", + "rector/rector": "^2.0.0", + "shipmonk/composer-dependency-analyser": "^1.6", + "symfony/process": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/ux-twig-component": "^2.2.0", + "twig/cache-extra": "^3.2" + }, + "bin": [ + "bin/twig-cs-fixer" + ], + "type": "coding-standard", + "autoload": { + "psr-4": { + "TwigCsFixer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vincent Langlet" + } + ], + "description": "A tool to automatically fix Twig code style", + "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", + "support": { + "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/VincentLanglet", + "type": "github" + } + ], + "time": "2026-08-30T16:07:04+00:00" } ], - "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -2603,6 +8785,6 @@ "ext-ctype": "*", "ext-iconv": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.9.0" } diff --git a/config/bundles.php b/config/bundles.php index 49d3fb6..93280e5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,4 +2,9 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], ]; diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 0000000..f7653e9 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml new file mode 100644 index 0000000..ad874af --- /dev/null +++ b/config/packages/debug.yaml @@ -0,0 +1,5 @@ +when@dev: + debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..1e06583 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -8,6 +8,14 @@ framework: #esi: true #fragments: true + trusted_proxies: '127.0.0.1,REMOTE_ADDR' + + # https://symfony.com/doc/current/http_client.html#scoping-client + http_client: + scoped_clients: + broker.client: + base_uri: '%env(APP_BROKER_BASE_URI)%' + when@test: framework: test: true diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..3f795d9 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml new file mode 100644 index 0000000..456fc45 --- /dev/null +++ b/config/packages/web_profiler.yaml @@ -0,0 +1,11 @@ +when@dev: + web_profiler: + toolbar: true + + framework: + profiler: true + +when@test: + framework: + profiler: + collect: false diff --git a/config/reference.php b/config/reference.php index ececc72..1af6c7d 100644 --- a/config/reference.php +++ b/config/reference.php @@ -267,7 +267,7 @@ * formats?: array>, * }, * assets?: bool|array{ // Assets configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false * version_strategy?: scalar|Param|null, // Default: null * version?: scalar|Param|null, // Default: null @@ -286,7 +286,7 @@ * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * paths?: Param|string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true @@ -352,7 +352,7 @@ * }>, * }, * serializer?: bool|array{ // Serializer configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true * name_converter?: scalar|Param|null, * circular_reference_handler?: scalar|Param|null, @@ -369,7 +369,7 @@ * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * magic_call?: bool|Param, // Default: false * magic_get?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true @@ -377,11 +377,11 @@ * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true * }, * cache?: array{ // Cache configuration @@ -471,7 +471,7 @@ * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, @@ -701,28 +701,140 @@ * }, * }, * } + * @psalm-type MakerConfig = array{ + * root_namespace?: scalar|Param|null, // Default: "App" + * generate_final_classes?: bool|Param, // Default: true + * generate_final_entities?: bool|Param, // Default: false + * } + * @psalm-type TwigConfig = array{ + * form_themes?: list, + * globals?: array + * }>, + * autoescape_service?: scalar|Param|null, // Default: null + * autoescape_service_method?: scalar|Param|null, // Default: null + * cache?: scalar|Param|null, // Default: true + * charset?: scalar|Param|null, // Default: "%kernel.charset%" + * debug?: bool|Param, // Default: "%kernel.debug%" + * strict_variables?: bool|Param, // Default: "%kernel.debug%" + * auto_reload?: scalar|Param|null, + * optimizations?: int|Param, + * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" + * file_name_pattern?: Param|string|list, + * paths?: array, + * date?: array{ // The default format options used by the date filter. + * format?: scalar|Param|null, // Default: "F j, Y H:i" + * interval_format?: scalar|Param|null, // Default: "%d days" + * timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null + * }, + * number_format?: array{ // The default format options for the number_format filter. + * decimals?: int|Param, // Default: 0 + * decimal_point?: scalar|Param|null, // Default: "." + * thousands_separator?: scalar|Param|null, // Default: "," + * }, + * mailer?: array{ + * html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null + * }, + * } + * @psalm-type TwigExtraConfig = array{ + * cache?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * html?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * markdown?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * intl?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * cssinliner?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * inky?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * string?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * commonmark?: array{ + * renderer?: array{ // Array of options for rendering HTML. + * block_separator?: scalar|Param|null, + * inner_separator?: scalar|Param|null, + * soft_break?: scalar|Param|null, + * }, + * html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input. + * allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true + * max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807 + * max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807 + * slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created. + * instance?: mixed, + * max_length?: int|Param, // Default: 255 + * unique?: mixed, + * }, + * commonmark?: array{ // Array of options for configuring the CommonMark core extension. + * enable_em?: bool|Param, // Default: true + * enable_strong?: bool|Param, // Default: true + * use_asterisk?: bool|Param, // Default: true + * use_underscore?: bool|Param, // Default: true + * unordered_list_markers?: list, + * }, + * ... + * }, + * } + * @psalm-type WebProfilerConfig = array{ + * toolbar?: bool|array{ // Profiler toolbar configuration + * enabled?: bool|Param, // Default: false + * ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false + * }, + * intercept_redirects?: bool|Param, // Default: false + * excluded_ajax_paths?: scalar|Param|null, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" + * } + * @psalm-type DebugConfig = array{ + * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 + * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 + * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 + * dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null + * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * maker?: MakerConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * web_profiler?: WebProfilerConfig, + * debug?: DebugConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * web_profiler?: WebProfilerConfig, * }, * ... + */ +return [ + 'app' => ['path' => './assets/app.js', 'entrypoint' => true], + 'septima-widget' => ['version' => '3.23.0'], +]; diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..23c7172 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,20 @@ +parameters: + level: 6 + paths: + - bin/ + - config/ + - public/ + - src/ + # - tests/ + + excludePaths: + - src/Kernel.php + + ignoreErrors: + - message: '#Access to protected property proj4php\\Point::\$(x|y).#' + path: src/Command/BrokerImportGeoJson.php + + - messages: + - '#contains generic class RecursiveArrayIterator but does not specify its types#' + - '#Call to an undefined method RecursiveIterator::getArrayCopy\(\).#' + path: src/GeometryHelper.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..22bd879 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/src/Command/BrokerEntityDelete.php b/src/Command/BrokerEntityDelete.php new file mode 100644 index 0000000..98bba64 --- /dev/null +++ b/src/Command/BrokerEntityDelete.php @@ -0,0 +1,96 @@ +request(Request::METHOD_GET, '/ngsi-ld/v1/types')->toArray(); + $entityTypes = $data['typeList'] ?? []; + } + + $limit = 1000; + foreach ($entityTypes as $entityType) { + while (true) { + /** + * @var array $entities + */ + $response = $brokerClient->request(Request::METHOD_GET, '/ngsi-ld/v1/entities', [ + 'query' => [ + 'type' => $entityType, + 'limit' => $limit, + ], + ]); + + $entities = $response->toArray(); + $count = count($entities); + if ($count > 0) { + $io->writeln(match ($count) { + 1 => sprintf('Deleting one %s entity …', $entityType), + default => sprintf('Deleting %d %s entities …', $count, $entityType), + }); + $io->progressStart($count); + foreach ($entities as $entity) { + $brokerClient->request(Request::METHOD_DELETE, '/ngsi-ld/v1/entities/' . urlencode($entity['id'])) + ->getStatusCode(); + $io->progressAdvance(); + } + $io->progressFinish(); + } + + if ($count < $limit) { + break; + } + } + } + + return Command::SUCCESS; + } + + /** + * Get link URL. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + */ + private function getLinkUrl(ResponseInterface $response, string $rel): ?string + { + $links = $response->getHeaders()['link'] ?? []; + foreach ($links as $link) { + if (preg_match_all('/<(?P[^>]+)>;\s*rel="(?P[^"]+)"/', (string)$link, $matches, PREG_SET_ORDER)) { + if ($rel === ($matches[0]['rel'] ?? null)) { + return $matches[0]['url'] ?? null; + } + } + } + + return null; + } +} diff --git a/src/Command/BrokerImportGeoJson.php b/src/Command/BrokerImportGeoJson.php new file mode 100644 index 0000000..b70ad4f --- /dev/null +++ b/src/Command/BrokerImportGeoJson.php @@ -0,0 +1,101 @@ +request(Request::METHOD_GET, $dataUrl)->toArray(); + $features = $data['features'] ?? null; + $sourceProjection = $data['crs']['properties']['name'] ?? null; + $sourceProjection = null !== $sourceProjection + ? Crs::from($sourceProjection) + : Crs::geoJson(); + + $count = count($features); + if ($count > 0) { + $io->writeln(match ($count) { + 1 => sprintf('Creating one %s entity …', $entityType), + default => sprintf('Creating %d %s entities …', $count, $entityType), + }); + $io->progressStart($count); + foreach ($features as $index => $feature) { + $entity = [ + 'type' => $entityType, + 'id' => sprintf('%s:%04d', $entityType, $index), + ]; +// foreach (['geometry'] as $key) { +// if (array_key_exists($key, $feature)) { +// $entity[self::RESERVED_PROPERTY_NAME_PREFIX.$key] = $feature[$key]; +// } +// } + foreach ($feature['properties'] as $name => $value) { + if (null === $value) { + continue; + } + if (in_array($name, ['id', 'type'])) { + $name = self::RESERVED_PROPERTY_NAME_PREFIX.$name; + } + $entity[$name] = [ + 'type' => 'Property', + 'value' => $value, + ]; + } + $geometry = $geometryHelper->transformGeoJsonGeometry($feature['geometry'], from: $sourceProjection); + $entity['geometry'] = $geometry; + $centroid = $geometryHelper->getCentroid($geometry); + // Compute location from geometry. The location must be a single point (the "spatial location" of the entity). + $entity['location'] = [ + 'type' => 'geo:json', + 'value' => [ + 'type' => 'Point', + 'coordinates' => [$centroid->x, $centroid->y], + ], + ]; + $response = $brokerClient->request(Request::METHOD_POST, '/ngsi-ld/v1/entities', [ + 'json' => $entity, + ]); + + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + $io->error($response->getContent(false)); + $io->writeln(json_encode($entity, JSON_PRETTY_PRINT)); + + return Command::FAILURE; + } + $io->progressAdvance(); + } + $io->progressFinish(); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/ConvertPointCommand.php b/src/Command/ConvertPointCommand.php new file mode 100644 index 0000000..4a1c3a0 --- /dev/null +++ b/src/Command/ConvertPointCommand.php @@ -0,0 +1,46 @@ +createProjection($from->value)); + $transformedPoint = $helper->transformPoint($point, to: $to); + + $io->writeln([ + $point->toShortString(), + $from->name, + $transformedPoint->toShortString(), + $to->name, + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/DataController.php b/src/Controller/DataController.php new file mode 100644 index 0000000..38766ad --- /dev/null +++ b/src/Controller/DataController.php @@ -0,0 +1,65 @@ + '[^.]+', + '_format' => 'json|geojson', + ], + defaults: ['_format' => self::FORMAT_JSON], + )] + public function index(Request $request, string $path, string $_format, + HttpClientInterface $brokerClient, + ): JsonResponse { + $path = '/'.ltrim($path, '/'); + $headers = $request->headers->all(); + // Set "accept" header for clients that cannot do it themselves. + if (self::FORMAT_GEOJSON === $_format) { + $headers['accept'] = self::APPLICATION_GEOJSON; + } + // Exclude some headers from the proxy call to the broker. + $headers = array_filter( + $headers, + static fn (string $name) => !in_array($name, ['authorization', 'host']) + && !str_starts_with($name, 'x-forwarded-'), + ARRAY_FILTER_USE_KEY + ); + $response = $brokerClient->request($request->getMethod(), $path, [ + 'query' => $request->query->all(), + 'headers' => $headers, + ]); + + return new JsonResponse( + data: $response->getContent(), + status: $response->getStatusCode(), + headers: $response->getHeaders(), + json: true, + ); + } + + #[Route('/test', name: 'data_test')] + public function test(): JsonResponse + { + $data = Yaml::parseFile(__DIR__.'/data.yaml'); + + return new JsonResponse($data); + } +} diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php new file mode 100644 index 0000000..674b131 --- /dev/null +++ b/src/Controller/DefaultController.php @@ -0,0 +1,18 @@ + Requirement::CATCH_ALL], methods: [Request::METHOD_GET])] + public function index(?string $path = null): Response + { + return $this->render(null === $path ? 'default/index.html.twig' : sprintf('default/%s.html.twig', $path)); + } +} diff --git a/src/GeometryHelper.php b/src/GeometryHelper.php new file mode 100644 index 0000000..05006c4 --- /dev/null +++ b/src/GeometryHelper.php @@ -0,0 +1,85 @@ + + */ +final class GeometryHelper +{ + /** + * @return array{ + * 0: float, + * 1: float, + * } + */ + public function transformCoordinates(float $x, float $y, Crs|string $from, string|Crs|null $to = Crs::EPSG_4326): array + { + $point = new Point($x, $y); + $transformed = $this->transformPoint($point, $to, $from); + + return array_slice($transformed->toArray(), 0, 2); + } + + public function transformPoint(Point $point, Crs|string|null $from = null, Crs|string $to = Crs::EPSG_4326): Point + { + if (null === $point->getProjection() && null === $from) { + throw new \LogicException('Projection must be specified when point has no projection'); + } + + $proj4 = new Proj4php(); + $fromProj = $point->getProjection() ?? new Proj(is_string($from) ? $from : $from->value, $proj4); + $toProj = new Proj(is_string($to) ? $to : $to->value, $proj4); + + // Proj4php::transform modifies its argument! + return $proj4->transform($fromProj, $toProj, clone $point); + } + + public function createProjection(string $srsCode): Proj + { + $proj4 = new Proj4php(); + + return new Proj($srsCode, $proj4); + } + + /** + * @param Geometry $geometry + * + * @return Geometry + */ + public function transformGeoJsonGeometry(array $geometry, Crs|string|null $from, Crs|string $to = Crs::EPSG_4326): array + { + $arrayIterator = new \RecursiveArrayIterator($geometry); + $iterator = new \RecursiveIteratorIterator($arrayIterator, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $key => $value) { + if (is_array($value) && array_is_list($value) && 2 === count($value) && is_float($value[0]) && is_float($value[1])) { + $transformed = $this->transformCoordinates($value[0], $value[1], $to, $from); + // Am I stupid or is PHP stupid? + // https://stackoverflow.com/a/40483953 + $currentDepth = $iterator->getDepth(); + for ($subDepth = $currentDepth; $subDepth >= 0; --$subDepth) { + /** @var \RecursiveArrayIterator $subIterator */ + $subIterator = $iterator->getSubIterator($subDepth); + $subIterator->offsetSet($subIterator->key(), $subDepth === $currentDepth ? $transformed : $iterator->getSubIterator($subDepth + 1)->getArrayCopy()); + } + } + } + + return $arrayIterator->getArrayCopy(); + } + + /** + * @param Geometry $geometry + */ + public function getCentroid(array $geometry): Point + { + $centroid = \geoPHP::load(json_encode($geometry), 'geojson')->getCentroid(); + + return new Point($centroid->getX(), $centroid->getY()); + } +} diff --git a/src/GeometryHelper/Crs.php b/src/GeometryHelper/Crs.php new file mode 100644 index 0000000..8c67349 --- /dev/null +++ b/src/GeometryHelper/Crs.php @@ -0,0 +1,15 @@ + + + + + + {% block title %}Enter{% endblock %} + {% block stylesheets %} + + {% endblock %} + + {% block javascripts %} + + {% block importmap %}{{ importmap('app') }}{% endblock %} + {% endblock %} + + + {% block body %} +
+

{{ block('title') }}

+ + {% block flashes %} + {% for type, messages in app.flashes %} + {% for message in messages %} + + {% endfor %} + {% endfor %} + {% endblock %} + + {% block content %}{% endblock %} +
+{% endblock %} + + diff --git a/templates/default/hundeskov.html.twig b/templates/default/hundeskov.html.twig new file mode 100644 index 0000000..611eb6b --- /dev/null +++ b/templates/default/hundeskov.html.twig @@ -0,0 +1,113 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+ + + +{# +
+ + +#} +{% endblock %} diff --git a/templates/default/index.html.twig b/templates/default/index.html.twig new file mode 100644 index 0000000..af084f0 --- /dev/null +++ b/templates/default/index.html.twig @@ -0,0 +1,63 @@ +{% extends 'base.html.twig' %} + +{% block content %} + {% for path in ['hundeskov'] %} + {{ path }} + {% endfor %} + + +
#map
+ + + +{# +4.9 NGSI-LD Query Language: https://cim.etsi.org/NGSI-LD/official/clause-4.html#4.9 +4.10 NGSI-LD Geoquery Language: https://cim.etsi.org/NGSI-LD/official/clause-4.html#4.10 +#} +
    +{% for query in [ +{type: 'handicapparkering'}, +{type: 'hundeskov'}, +{type: 'toilet'}, +{q: 'beskrivelse==Handicaptoilet'}, + +{ +georel: 'near;maxDistance==20000', +geometry: 'Point', +coordinates: [10, 56]|json_encode, +}, + +{ +georel: 'near;maxDistance==2000', +geometry: 'Point', +coordinates: [10.236808047161853, 56.101226991176155]|json_encode, +}, + +{ +georel: 'near;maxDistance==8000', +geometry: 'Point', +coordinates: [10.236808047161853, 56.101226991176155]|json_encode, +}, + +] %} +{% set url = path('app_data', {path: 'ngsi-ld/v1/entities', _format: 'geojson'}|merge(query)) %} + +{# +Scorpio complains about "Bad Request Data." and says "Georelation is not valid" when `==` is URL encoded! +#} +{% set url = url|replace({('maxDistance=='|url_encode): 'maxDistance=='}) %} + +
  • {{ url }}
  • +{% endfor %} +
+ +Reset +
+{##} +{# https://github.com/Septima/widget-issues/issues/24#issuecomment-5478400091 #} + + +https://enter.ddev.site:33001/data/ngsi-ld/v1/entities.geojson?georel=near;maxDistance==2000&geometry=Point&coordinates=[8,40] + + +{% endblock %} diff --git a/tests/GeometryHelperTest.php b/tests/GeometryHelperTest.php new file mode 100644 index 0000000..000e9dc --- /dev/null +++ b/tests/GeometryHelperTest.php @@ -0,0 +1,61 @@ +transformCoordinates($input[0], $input[1], GeometryHelper::EPSG_GEO_JSON, GeometryHelper::EPSG_25832); + $this->assertEquals($expected, $actual); + } + + public function testTransformPoint(): void + { + $helper = new GeometryHelper(); + $input = new Point( + 576933.0181395365, 6218035.22691216, + ); + $expected = new Point( + 10.236808, 56.101227, + ); + $actual = $helper->transformPoint($input, GeometryHelper::EPSG_GEO_JSON, GeometryHelper::EPSG_25832); + $this->assertEquals($expected, $actual); + } + + public function testTransformGeoJsonGeometry(): void + { + $helper = new GeometryHelper(); + + $input = [ + 'type' => 'MultiPoint', + 'coordinates' => [ + [ + 576933.0181395365, 6218035.22691216, + ], + ], + ]; + $expected = [ + 'type' => 'MultiPoint', + 'coordinates' => [ + [ + // https://epsg.io/transform#s_srs=25832&t_srs=4326&x=576933.0181395&y=6218035.2269122 + 10.236808, 56.101227, + ], + ], + ]; + $actual = $helper->transformGeoJsonGeometry($input, from: GeometryHelper::EPSG_25832, to: GeometryHelper::EPSG_GEO_JSON); + $this->assertSame($expected, $actual); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +} From 190ace828e7cbe43e4788ae87f654e3fead06bd1 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Tue, 1 Sep 2026 10:57:52 +0200 Subject: [PATCH 04/59] Added ITKDev project template --- .ddev/docker-compose.scorpio.yaml | 2 +- .docker/data/.gitignore | 5 + .docker/data/README.md | 26 +++ .docker/nginx.conf | 34 ++++ .docker/templates/default.conf.template | 56 +++++++ .env | 4 + .github/workflows/changelog.yaml | 27 +++ .github/workflows/composer.yaml | 79 +++++++++ .github/workflows/javascript.yaml | 39 +++++ .github/workflows/markdown.yaml | 45 +++++ .github/workflows/php.yaml | 66 ++++++++ .github/workflows/styles.yaml | 40 +++++ .github/workflows/twig.yaml | 55 ++++++ .github/workflows/yaml.yaml | 45 +++++ .markdownlint.jsonc | 22 +++ .markdownlintignore | 12 ++ .prettierignore | 2 + .prettierrc.yaml | 18 ++ CHANGELOG.md | 10 ++ README.md | 14 +- Taskfile.yml | 42 ++--- assets/app.js | 4 +- composer.json | 28 ++-- composer.lock | 214 ++++++++++++------------ config/packages/cache.yaml | 2 +- config/packages/debug.yaml | 2 +- config/services.yaml | 2 +- docker-compose.dev.yml | 27 +++ docker-compose.redirect.yml | 15 ++ docker-compose.server.yml | 52 ++++++ docker-compose.yml | 110 ++++++++++++ phpstan.dist.neon | 4 +- rector.php | 18 ++ src/Command/BrokerEntityDelete.php | 13 +- src/Command/BrokerImportGeoJson.php | 8 - src/Controller/DataController.php | 4 +- src/GeometryHelper.php | 2 +- templates/default/hundeskov.html.twig | 4 +- templates/default/index.html.twig | 8 +- tests/{ => Unit}/GeometryHelperTest.php | 8 +- tests/bootstrap.php | 2 +- 41 files changed, 986 insertions(+), 184 deletions(-) create mode 100644 .docker/data/.gitignore create mode 100644 .docker/data/README.md create mode 100644 .docker/nginx.conf create mode 100644 .docker/templates/default.conf.template create mode 100644 .github/workflows/changelog.yaml create mode 100644 .github/workflows/composer.yaml create mode 100644 .github/workflows/javascript.yaml create mode 100644 .github/workflows/markdown.yaml create mode 100644 .github/workflows/php.yaml create mode 100644 .github/workflows/styles.yaml create mode 100644 .github/workflows/twig.yaml create mode 100644 .github/workflows/yaml.yaml create mode 100644 .markdownlint.jsonc create mode 100644 .markdownlintignore create mode 100644 .prettierignore create mode 100644 .prettierrc.yaml create mode 100644 CHANGELOG.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.redirect.yml create mode 100644 docker-compose.server.yml create mode 100644 docker-compose.yml create mode 100644 rector.php rename tests/{ => Unit}/GeometryHelperTest.php (88%) diff --git a/.ddev/docker-compose.scorpio.yaml b/.ddev/docker-compose.scorpio.yaml index e1f60a2..a8a12ec 100644 --- a/.ddev/docker-compose.scorpio.yaml +++ b/.ddev/docker-compose.scorpio.yaml @@ -1,5 +1,5 @@ networks: - scorpio: + scorpio: # https://docs.ddev.com/en/stable/users/extend/custom-docker-services/ services: diff --git a/.docker/data/.gitignore b/.docker/data/.gitignore new file mode 100644 index 0000000..4ce1020 --- /dev/null +++ b/.docker/data/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except +!.gitignore +!README.md diff --git a/.docker/data/README.md b/.docker/data/README.md new file mode 100644 index 0000000..8895d7b --- /dev/null +++ b/.docker/data/README.md @@ -0,0 +1,26 @@ +# .docker/data + +Please map persistent volumes to this directory on the servers. + +If a container needs to persist data between restarts you can map the relevant files in the container to ``docker/data/`. + +## RabbitMQ example +If you are using RabbitMQ running in a container as a message broker you need to configure a persistent volume for RabbitMQs data directory to avoid losing message on container restarts. + +```yaml +# docker-compose.server.override.yml + +services: + rabbit: + image: rabbitmq:3.9-management-alpine + hostname: "${COMPOSE_PROJECT_NAME}" + networks: + - app + - frontend + environment: + - "RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}" + - "RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}" + - "RABBITMQ_ERLANG_COOKIE=${RABBITMQ_ERLANG_COOKIE}" + volumes: + - ".docker/data/rabbitmq:/var/lib/rabbitmq/mnesia/" +``` diff --git a/.docker/nginx.conf b/.docker/nginx.conf new file mode 100644 index 0000000..ec278a5 --- /dev/null +++ b/.docker/nginx.conf @@ -0,0 +1,34 @@ +worker_processes auto; + +error_log /dev/stderr notice; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + proxy_temp_path /tmp/proxy_temp; + client_body_temp_path /tmp/client_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Note: set_real_ip_from is set in the server block + + log_format main '$http_x_real_ip - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + + gzip on; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.docker/templates/default.conf.template b/.docker/templates/default.conf.template new file mode 100644 index 0000000..36ddf04 --- /dev/null +++ b/.docker/templates/default.conf.template @@ -0,0 +1,56 @@ +server { + listen ${NGINX_PORT}; + server_name localhost; + + root ${NGINX_WEB_ROOT}; + + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + + set_real_ip_from 172.16.0.0/16; + set_real_ip_from 192.168.39.0/24; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + + location = /cron-metrics { + # Proxy to supercronic metrics + proxy_pass http://${NGINX_CRON_METRICS}/metrics; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + # try to serve file directly, fallback to index.php + try_files $uri /index.php$is_args$args; + } + + # Protect files and directories from prying eyes. + location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|.tar|.gz|.bz2|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ { + deny all; + return 404; + } + + location ~ ^/index\.php(/|$) { + fastcgi_buffers 16 32k; + fastcgi_buffer_size 64k; + fastcgi_busy_buffers_size 64k; + + fastcgi_pass ${NGINX_FPM_SERVICE}; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + + internal; + } + + location ~ \.php$ { + return 404; + } + + # Send log message to files symlinked to stdout/stderr. + error_log /dev/stderr; + access_log /dev/stdout main; +} diff --git a/.env b/.env index b9dbe2b..999fa0f 100644 --- a/.env +++ b/.env @@ -1,3 +1,7 @@ +COMPOSE_PROJECT_NAME=enter +COMPOSE_DOMAIN=enter.local.itkdev.dk +ITKDEV_TEMPLATE=symfony-8 + # In all environments, the following files are loaded if they exist, # the latter taking precedence over the former: # diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 0000000..09e309d --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,27 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/changelog.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Changelog +### +### Checks that changelog has been updated + +name: Changelog + +on: + pull_request: + +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml new file mode 100644 index 0000000..fd10569 --- /dev/null +++ b/.github/workflows/composer.yaml @@ -0,0 +1,79 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/composer.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Composer +### +### Validates composer.json and checks that it's normalized. +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### ``` +### +### Normalize `composer.json` by running +### +### ``` shell +### docker compose run --rm phpfpm composer normalize +### ``` + +name: Composer + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + composer-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer validate --strict + + composer-normalized: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer normalize --dry-run + + composer-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer audit --locked diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml new file mode 100644 index 0000000..7960aed --- /dev/null +++ b/.github/workflows/javascript.yaml @@ -0,0 +1,39 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/javascript.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony JavaScript (and TypeScript) +### +### Validates JavaScript files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: JavaScript + +on: + pull_request: + paths: &paths + - "assets/**/*.js" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + javascript-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.js' --check diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml new file mode 100644 index 0000000..cee4ff8 --- /dev/null +++ b/.github/workflows/markdown.yaml @@ -0,0 +1,45 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/markdown.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Markdown +### +### Lints Markdown files (`**/*.md`) in the project. +### +### [markdownlint-cli configuration +### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration), +### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually +### linted and how. +### +### #### Assumptions +### +### 1. A docker compose service named `markdownlint` for running `markdownlint` +### (from +### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli)) +### exists. + +name: Markdown + +on: + pull_request: + paths: &paths + - "**/*.md" + push: + branches: + - main + - develop + paths: *paths + +jobs: + markdown-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml new file mode 100644 index 0000000..b005255 --- /dev/null +++ b/.github/workflows/php.yaml @@ -0,0 +1,66 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/php.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony PHP +### +### Checks that PHP code adheres to the [Symfony coding +### standards](https://symfony.com/doc/current/contributing/code/standards.html). +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. 2. +### [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev friendsofphp/php-cs-fixer +### ``` +### +### Clean up and check code by running +### +### ``` shell +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff +### ``` +### +### > [!NOTE] The template adds `.php-cs-fixer.dist.php` as [a configuration +### > file for PHP CS +### > Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/config.rst) +### > and this makes it possible to override the actual configuration used in a +### > project by adding a more important configuration file, `.php-cs-fixer.php`. + +name: Symfony PHP + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "**/*.php" + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + # https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/usage.rst#the-check-command + docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.github/workflows/styles.yaml b/.github/workflows/styles.yaml new file mode 100644 index 0000000..d3b4588 --- /dev/null +++ b/.github/workflows/styles.yaml @@ -0,0 +1,40 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/styles.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony Styles (CSS and SCSS) +### +### Validates styles files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: Styles + +on: + pull_request: + paths: &paths + - "assets/**/*.css" + - "assets/**/*.scss" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + styles-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.{css,scss}' --check diff --git a/.github/workflows/twig.yaml b/.github/workflows/twig.yaml new file mode 100644 index 0000000..52536c3 --- /dev/null +++ b/.github/workflows/twig.yaml @@ -0,0 +1,55 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/twig.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Twig +### +### Validates Twig files +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev vincentlanglet/twig-cs-fixer +### ``` +### +### 3. A [Configuration +### file](https://github.com/VincentLanglet/Twig-CS-Fixer/blob/main/docs/configuration.md#configuration-file) +### in the root of the project defines which files to check and rules to use. + +name: Twig + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "**/*.twig" + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + twig-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/twig-cs-fixer lint diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 0000000..911097e --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,45 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/yaml.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### YAML +### +### Validates YAML files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. +### +### #### Symfony YAML +### +### Symfony's YAML config files use 4 spaces for indentation and single quotes. +### Therefore we use a [Prettier configuration +### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make +### Prettier format YAML files in the `config/` folder like Symfony expects. + +name: YAML + +on: + pull_request: + paths: &paths + - "**/*.yml" + - "**/*.yaml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..0253096 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,22 @@ +// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker. +// Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration) +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + }, + // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md + "no-inline-html": { + "allowed_elements": ["details", "summary"] + } +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..d143ace --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,12 @@ +# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files +vendor/ +node_modules/ +LICENSE.md +# Drupal +web/*.md +web/core/ +web/libraries/ +web/*/contrib/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8827682 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +_misc + diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..a99aea9 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,18 @@ +# This file is copied from config/symfony/yaml/.prettierrc.yaml in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://prettier.io/docs/configuration +overrides: + # https://taskfile.dev/docs/styleguide + - files: + - "Taskfile.{yml,yaml}" + options: + tabWidth: 2 + singleQuote: true + + # Symfony config + - files: + - "config/**/*.{yml,yaml}" + options: + tabWidth: 4 + singleQuote: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf48d94 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/itk-dev/enter diff --git a/README.md b/README.md index 8e132d2..95afbea 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ task site:update ddev launch ``` +Run `task` to see what cool task are available. Running `ddev` can help with other stuff. + ## Broker A [Scorpio Broker](https://scorpio.readthedocs.io/) is part of the development setup. @@ -38,7 +40,6 @@ ddev console app:broker:import:geojson hundeskov 'https://webkort.aarhuskommune. ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=hundeskov" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location", "geometry"])))' ``` - ### Broker API request examples @@ -117,7 +118,6 @@ ddev exec "curl --silent --header 'accept: application/geo+json' http://scorpio. * - --- * @@ -150,8 +150,12 @@ ddev console app:import:geojson toilet 'https://webkort.aarhuskommune.dk/spatial * [Public toilets in Aarhus Municipality](https://www.opendata.dk/city-of-aarhus/toiletter-i-aarhus-kommune) - * [Offentlige toiletter](http://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter): -* [Parking in Aarhus Municipality – Zones, Permits and Spaces](https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune) - * [Handicapparkering](https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap): + * [Offentlige + toiletter](http://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter): + +* [Parking in Aarhus Municipality – Zones, Permits and + Spaces](https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune) + * [Handicapparkering](https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap): + * diff --git a/Taskfile.yml b/Taskfile.yml index 400d1f1..49f5e72 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -23,15 +23,17 @@ tasks: test: desc: Run all tests cmds: - - task: test:application - - task: test:integration + - task: test:unit + # - task: test:application + # - task: test:integration - test:integration: &test_task + test:unit: &test_task env: APP_ENV: test desc: 'Run all {{.TASK | replace "test:" "" }} tests' cmd: ddev php bin/phpunit tests/{{.TASK | replace "test:" "" | title }} + test:integration: *test_task test:application: *test_task coding-standards:apply: @@ -108,38 +110,26 @@ tasks: coding-standards:php:apply: desc: 'Apply coding standards for PHP' cmds: - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/rector - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/php-cs-fixer fix + - ddev php vendor/bin/rector + - ddev php vendor/bin/php-cs-fixer fix coding-standards:php:check: desc: 'Apply and check coding standards for PHP' cmds: - task: coding-standards:php:apply - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/rector --dry-run - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/php-cs-fixer check + - ddev php vendor/bin/rector --dry-run + - ddev php vendor/bin/php-cs-fixer check coding-standards:twig:apply: desc: 'Apply coding standards for Twig' cmds: - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/twig-cs-fixer fix + - ddev php vendor/bin/twig-cs-fixer fix coding-standards:twig:check: desc: 'Apply and check coding standards for Twig' cmds: - task: coding-standards:twig:apply - - task: compose - vars: - TASK_ARGS: exec phpfpm vendor/bin/twig-cs-fixer check + - ddev php vendor/bin/twig-cs-fixer check coding-standards:yaml:apply: desc: 'Apply coding standards for YAML' @@ -172,6 +162,16 @@ tasks: vars: TASK_ARGS: run --rm --no-deps phpfpm vendor/bin/phpstan + compose: + internal: true + cmd: 'docker compose {{.TASK_ARGS}} {{.CLI_ARGS}}' + + composer: + internal: true + task: compose + vars: + TASK_ARGS: run --rm --no-deps phpfpm composer + default: cmds: - task --list-all diff --git a/assets/app.js b/assets/app.js index d7879d7..d0d0685 100644 --- a/assets/app.js +++ b/assets/app.js @@ -4,8 +4,8 @@ * This file will be included onto the page via the importmap() Twig function, * which should already be in your base.html.twig. */ -import './styles/app.css'; -import WidgetAPI from 'septima-widget' +import "./styles/app.css"; +import WidgetAPI from "septima-widget"; // const widget = new WidgetAPI('#map', { // map: { diff --git a/composer.json b/composer.json index d50893f..b3bcf60 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,7 @@ { - "license": "proprietary", + "name": "itk-dev/enter", + "description": "Enter", + "license": "MIT", "type": "project", "require": { "php": ">=8.4", @@ -7,33 +9,33 @@ "ext-iconv": "*", "itamair/geophp": "^1.12", "phpdocumentor/reflection-docblock": "^6.0", - "phpstan/phpdoc-parser": "^2.3", + "phpstan/phpdoc-parser": "^2.3.5", "proj4php/proj4php": "^2.0", "symfony/asset": "~8.1.0", "symfony/asset-mapper": "~8.1.0", - "symfony/console": "~8.1.0", - "symfony/dotenv": "~8.1.0", + "symfony/console": "~8.1.6", + "symfony/dotenv": "~8.1.6", "symfony/flex": "^2", - "symfony/framework-bundle": "~8.1.0", - "symfony/http-client": "~8.1.0", + "symfony/framework-bundle": "~8.1.6", + "symfony/http-client": "~8.1.6", "symfony/property-access": "~8.1.0", - "symfony/property-info": "~8.1.0", + "symfony/property-info": "~8.1.6", "symfony/runtime": "~8.1.0", - "symfony/serializer": "~8.1.0", + "symfony/serializer": "~8.1.6", "symfony/twig-bundle": "~8.1.0", - "symfony/yaml": "~8.1.0", + "symfony/yaml": "~8.1.6", "twig/extra-bundle": "^2.12 || ^3.0", "twig/twig": "^2.12 || ^3.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.52", - "friendsofphp/php-cs-fixer": "^3.95", - "phpstan/phpstan": "^2.2", + "friendsofphp/php-cs-fixer": "^3.95.24", + "phpstan/phpstan": "^2.2.12", "phpunit/phpunit": "^13.3", "rector/rector": "^2.6", "symfony/browser-kit": "~8.1.0", - "symfony/css-selector": "~8.1.0", - "symfony/debug-bundle": "8.1.*", + "symfony/css-selector": "~8.1.6", + "symfony/debug-bundle": "~8.1.0", "symfony/maker-bundle": "^1.67", "symfony/stopwatch": "~8.1.0", "symfony/web-profiler-bundle": "~8.1.0", diff --git a/composer.lock b/composer.lock index 782be9a..2b3d45f 100644 --- a/composer.lock +++ b/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": "5bd73ae1836b8ed57dcd7a8086881f80", + "content-hash": "89753fcad5de669970a5e88087a1a523", "packages": [ { "name": "composer/semver", @@ -356,16 +356,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { @@ -397,9 +397,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-08-31T16:05:28+00:00" }, { "name": "proj4php/proj4php", @@ -820,16 +820,16 @@ }, { "name": "symfony/cache", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310" + "reference": "91a2b338b18de1400ba38faae3f1e160d3eefc28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/c12be586c0e4798e649e7501aa9181ddc9d93310", - "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310", + "url": "https://api.github.com/repos/symfony/cache/zipball/91a2b338b18de1400ba38faae3f1e160d3eefc28", + "reference": "91a2b338b18de1400ba38faae3f1e160d3eefc28", "shasum": "" }, "require": { @@ -895,7 +895,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.5" + "source": "https://github.com/symfony/cache/tree/v8.1.6" }, "funding": [ { @@ -915,7 +915,7 @@ "type": "tidelift" } ], - "time": "2026-08-19T08:32:28+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/cache-contracts", @@ -1077,16 +1077,16 @@ }, { "name": "symfony/console", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129" + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d07c06839e33047e2c894a6793248f3fb66c8129", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", "shasum": "" }, "require": { @@ -1153,7 +1153,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.5" + "source": "https://github.com/symfony/console/tree/v8.1.6" }, "funding": [ { @@ -1173,7 +1173,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T14:29:57+00:00" + "time": "2026-08-25T14:18:42+00:00" }, { "name": "symfony/dependency-injection", @@ -1329,7 +1329,7 @@ }, { "name": "symfony/dotenv", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", @@ -1379,7 +1379,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.1.2" + "source": "https://github.com/symfony/dotenv/tree/v8.1.6" }, "funding": [ { @@ -1650,16 +1650,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/6b2f4a0eeb28b5d74f90862592923a654bc629b3", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { @@ -1697,7 +1697,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.5" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -1717,7 +1717,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", @@ -1862,16 +1862,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea" + "reference": "18ea259ef00ecbd13ce87fcbed3978758154e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/1c7b41364312c67376a560e35c7f7f0be0c1b4ea", - "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/18ea259ef00ecbd13ce87fcbed3978758154e5a0", + "reference": "18ea259ef00ecbd13ce87fcbed3978758154e5a0", "shasum": "" }, "require": { @@ -1982,7 +1982,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.1.5" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.6" }, "funding": [ { @@ -2002,20 +2002,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:39:55+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/http-client", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d" + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", "shasum": "" }, "require": { @@ -2079,7 +2079,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.5" + "source": "https://github.com/symfony/http-client/tree/v8.1.6" }, "funding": [ { @@ -2099,20 +2099,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T14:03:40+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -2161,7 +2161,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -2181,20 +2181,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", "shasum": "" }, "require": { @@ -2242,7 +2242,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" }, "funding": [ { @@ -2262,20 +2262,20 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:59:12+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", "shasum": "" }, "require": { @@ -2352,7 +2352,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" }, "funding": [ { @@ -2372,7 +2372,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:45:00+00:00" + "time": "2026-08-30T21:40:49+00:00" }, { "name": "symfony/polyfill-deepclone", @@ -2876,16 +2876,16 @@ }, { "name": "symfony/property-info", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53" + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/b335f8e7fb1440ed3448fb33340efc6127678e53", - "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53", + "url": "https://api.github.com/repos/symfony/property-info/zipball/d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", "shasum": "" }, "require": { @@ -2938,7 +2938,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.5" + "source": "https://github.com/symfony/property-info/tree/v8.1.6" }, "funding": [ { @@ -2958,11 +2958,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T08:48:43+00:00" }, { "name": "symfony/routing", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", @@ -3018,7 +3018,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.5" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -3126,16 +3126,16 @@ }, { "name": "symfony/serializer", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024" + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/9c064d505c661e3e17aa8999870f4fb0ed05e024", - "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024", + "url": "https://api.github.com/repos/symfony/serializer/zipball/931151b12d6e2d4996ce2ce8e7b180d35455f37a", + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a", "shasum": "" }, "require": { @@ -3201,7 +3201,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.5" + "source": "https://github.com/symfony/serializer/tree/v8.1.6" }, "funding": [ { @@ -3221,20 +3221,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-08-30T08:48:47+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -3288,7 +3288,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -3308,7 +3308,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", @@ -3845,16 +3845,16 @@ }, { "name": "symfony/var-exporter", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" + "reference": "802362f41128c430fd6685491e1fb72127fae78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", + "reference": "802362f41128c430fd6685491e1fb72127fae78a", "shasum": "" }, "require": { @@ -3904,7 +3904,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" }, "funding": [ { @@ -3924,20 +3924,20 @@ "type": "tidelift" } ], - "time": "2026-08-13T16:11:28+00:00" + "time": "2026-08-29T06:30:03+00:00" }, { "name": "symfony/yaml", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", "shasum": "" }, "require": { @@ -3980,7 +3980,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.5" + "source": "https://github.com/symfony/yaml/tree/v8.1.6" }, "funding": [ { @@ -4000,7 +4000,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "twig/extra-bundle", @@ -5165,16 +5165,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.23", + "version": "v3.95.24", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "b2932a5af3157a0c28324b1efe5e3b556dee09c4" + "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b2932a5af3157a0c28324b1efe5e3b556dee09c4", - "reference": "b2932a5af3157a0c28324b1efe5e3b556dee09c4", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9a4b8050f8768646bf3693ee7416978ceeca6206", + "reference": "9a4b8050f8768646bf3693ee7416978ceeca6206", "shasum": "" }, "require": { @@ -5257,7 +5257,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.23" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.24" }, "funding": [ { @@ -5265,7 +5265,7 @@ "type": "github" } ], - "time": "2026-08-26T19:54:54+00:00" + "time": "2026-08-31T15:31:12+00:00" }, { "name": "justinrainbow/json-schema", @@ -5697,11 +5697,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.10", + "version": "2.2.12", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/36d1509c998b0602811824143526b4a9ee2774e7", - "reference": "36d1509c998b0602811824143526b4a9ee2774e7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/174b0d88710f00a42598886504dd7a146f91ace5", + "reference": "174b0d88710f00a42598886504dd7a146f91ace5", "shasum": "" }, "require": { @@ -5757,7 +5757,7 @@ "type": "github" } ], - "time": "2026-08-30T12:46:16+00:00" + "time": "2026-08-31T19:09:43+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8049,16 +8049,16 @@ }, { "name": "symfony/css-selector", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/a291fb5adb65f52a4bb315db2d803698315dc64d", - "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -8094,7 +8094,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.5" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -8114,7 +8114,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/debug-bundle", @@ -8433,7 +8433,7 @@ }, { "name": "symfony/process", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -8474,7 +8474,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.5" + "source": "https://github.com/symfony/process/tree/v8.1.6" }, "funding": [ { diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 6899b72..c3eb53d 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -16,4 +16,4 @@ framework: # Namespaced pools use the above "app" backend by default #pools: - #my.dedicated.cache: null + #my.dedicated.cache: null diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml index ad874af..54a4821 100644 --- a/config/packages/debug.yaml +++ b/config/packages/debug.yaml @@ -2,4 +2,4 @@ when@dev: debug: # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. # See the "server:dump" command to start a new server. - dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" + dump_destination: 'tcp://%env(VAR_DUMPER_SERVER)%' diff --git a/config/services.yaml b/config/services.yaml index 79b8ce2..7adddb9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,7 +11,7 @@ parameters: services: # default configuration for services in *this* file _defaults: - autowire: true # Automatically injects dependencies in your services. + autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. # makes classes in src/ available to be used as services diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..265ede4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,27 @@ +# itk-version: 3.2.4 +services: + phpfpm: + environment: + - PHP_SENDMAIL_PATH=/usr/sbin/sendmail -S mail:1025 + + nginx: + labels: + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=ITKBasicAuth@file" + + mail: + image: axllent/mailpit + restart: unless-stopped + networks: + - app + - frontend + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}-http.rule=Host(`mail.${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}-http.entrypoints=web" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}-http.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}.rule=Host(`mail.${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}.entrypoints=websecure" + - "traefik.http.services.mail_${COMPOSE_PROJECT_NAME:?}.loadbalancer.server.port=8025" + - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME:?}.middlewares=ITKMailhogAuth@file" diff --git a/docker-compose.redirect.yml b/docker-compose.redirect.yml new file mode 100644 index 0000000..4710035 --- /dev/null +++ b/docker-compose.redirect.yml @@ -0,0 +1,15 @@ +# itk-version: 3.2.4 +services: + nginx: + labels: + # Add www before domain and set redirect to non-www + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}-http.rule=Host(`www.${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}-http.entrypoints=web" + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}-http.middlewares=redirect-to-https,non_www" + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}.rule=Host(`www.${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}.entrypoints=websecure" + - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}.middlewares=non_www" + + - traefik.http.middlewares.non_www.redirectregex.regex=^(http|https)?://(?:www\.)?(.+) + - traefik.http.middlewares.non_www.redirectregex.replacement=https://$${2:?} + - traefik.http.middlewares.non_www.redirectregex.permanent=true diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..75148ed --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,52 @@ +# itk-version: 3.2.4 +networks: + frontend: + external: true + app: + driver: bridge + internal: false + +services: + phpfpm: + image: itkdev/php8.4-fpm:alpine + restart: unless-stopped + networks: + - app + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - PHP_MAX_EXECUTION_TIME=30 + - PHP_MEMORY_LIMIT=128M + volumes: + - .:/app + + nginx: + image: nginxinc/nginx-unprivileged:alpine + restart: unless-stopped + networks: + - app + - frontend + depends_on: + - phpfpm + volumes: + - ./.docker/templates:/etc/nginx/templates:ro + - ./.docker/nginx.conf:/etc/nginx/nginx.conf:ro + - .:/app + environment: + NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9000 + NGINX_CRON_METRICS: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9746 + NGINX_WEB_ROOT: /app/public + NGINX_PORT: 8080 + NGINX_MAX_BODY_SIZE: 5M + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.entrypoints=web" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.entrypoints=websecure" + # Cron-metrics protection. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c8f69a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,110 @@ +# itk-version: 3.2.4 +networks: + frontend: + external: true + app: + driver: bridge + internal: false + +services: + mariadb: + image: itkdev/mariadb:latest + networks: + - app + ports: + - "3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 10s + interval: 10s + timeout: 5s + retries: 3 + environment: + - MYSQL_ROOT_PASSWORD=password + - MYSQL_USER=db + - MYSQL_PASSWORD=db + - MYSQL_DATABASE=db + #- ENCRYPT=1 # Uncomment to enable database encryption. + + phpfpm: + image: itkdev/php8.4-fpm:latest + user: ${COMPOSE_USER:-deploy} + networks: + - app + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - PHP_XDEBUG_MODE=${PHP_XDEBUG_MODE:-off} + - PHP_MAX_EXECUTION_TIME=30 + - PHP_MEMORY_LIMIT=256M + # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail + - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from + - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN:?} + - PHP_IDE_CONFIG=serverName=localhost + depends_on: + mariadb: + condition: service_healthy + volumes: + - .:/app + + nginx: + image: nginxinc/nginx-unprivileged:alpine + networks: + - app + - frontend + depends_on: + - phpfpm + ports: + - "8080" + volumes: + - ./.docker/templates:/etc/nginx/templates:ro + - .:/app + environment: + NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9000 + NGINX_CRON_METRICS: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9746 + NGINX_WEB_ROOT: /app/public + NGINX_PORT: 8080 + NGINX_MAX_BODY_SIZE: 5M + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_DOMAIN:?}`)" + # HTTPS config - uncomment to enable redirect from :80 to :443 + # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=redirect-to-https" + # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + # Cron-metrics protection. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + + mail: + image: axllent/mailpit + networks: + - app + - frontend + ports: + - "1025" + - "8025" + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}mail.rule=Host(`mail-${COMPOSE_DOMAIN:?}`)" + - "traefik.http.services.${COMPOSE_PROJECT_NAME:?}mail.loadbalancer.server.port=8025" + + # Code checks tools + markdownlint: + image: itkdev/markdownlint + profiles: + - dev + volumes: + - ./:/md + + prettier: + # Prettier does not (yet, fcf. + # https://github.com/prettier/prettier/issues/15206) have an official + # docker image. + # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) + image: jauderho/prettier + profiles: + - dev + volumes: + - ./:/work diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 23c7172..38793b8 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -15,6 +15,6 @@ parameters: path: src/Command/BrokerImportGeoJson.php - messages: - - '#contains generic class RecursiveArrayIterator but does not specify its types#' - - '#Call to an undefined method RecursiveIterator::getArrayCopy\(\).#' + - "#contains generic class RecursiveArrayIterator but does not specify its types#" + - '#Call to an undefined method RecursiveIterator::getArrayCopy\(\).#' path: src/GeometryHelper.php diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..18a3d91 --- /dev/null +++ b/rector.php @@ -0,0 +1,18 @@ +withPaths([ + __DIR__.'/assets', + __DIR__.'/config', + __DIR__.'/src', + __DIR__.'/tests', + ]) + // uncomment to reach your current PHP version + ->withPhpSets(php85: true) + ->withTypeCoverageLevel(0) + ->withDeadCodeLevel(0) + ->withCodeQualityLevel(0); diff --git a/src/Command/BrokerEntityDelete.php b/src/Command/BrokerEntityDelete.php index 98bba64..d1da87d 100644 --- a/src/Command/BrokerEntityDelete.php +++ b/src/Command/BrokerEntityDelete.php @@ -21,14 +21,13 @@ class BrokerEntityDelete * @param string[]|null $entityTypes */ public function __invoke( - SymfonyStyle $io, + SymfonyStyle $io, HttpClientInterface $brokerClient, #[Argument(name: 'type')] - ?array $entityTypes = null, + ?array $entityTypes = null, #[Option] - bool $all = false, - ): int - { + bool $all = false, + ): int { if ($all) { $data = $brokerClient->request(Request::METHOD_GET, '/ngsi-ld/v1/types')->toArray(); $entityTypes = $data['typeList'] ?? []; @@ -59,7 +58,7 @@ public function __invoke( }); $io->progressStart($count); foreach ($entities as $entity) { - $brokerClient->request(Request::METHOD_DELETE, '/ngsi-ld/v1/entities/' . urlencode($entity['id'])) + $brokerClient->request(Request::METHOD_DELETE, '/ngsi-ld/v1/entities/'.urlencode($entity['id'])) ->getStatusCode(); $io->progressAdvance(); } @@ -84,7 +83,7 @@ private function getLinkUrl(ResponseInterface $response, string $rel): ?string { $links = $response->getHeaders()['link'] ?? []; foreach ($links as $link) { - if (preg_match_all('/<(?P[^>]+)>;\s*rel="(?P[^"]+)"/', (string)$link, $matches, PREG_SET_ORDER)) { + if (preg_match_all('/<(?P[^>]+)>;\s*rel="(?P[^"]+)"/', (string) $link, $matches, PREG_SET_ORDER)) { if ($rel === ($matches[0]['rel'] ?? null)) { return $matches[0]['url'] ?? null; } diff --git a/src/Command/BrokerImportGeoJson.php b/src/Command/BrokerImportGeoJson.php index b70ad4f..da2839d 100644 --- a/src/Command/BrokerImportGeoJson.php +++ b/src/Command/BrokerImportGeoJson.php @@ -31,9 +31,6 @@ public function __invoke( #[Argument()] string $dataUrl, ): int { - // $entityType = 'toilet'; - // // https://www.opendata.dk/city-of-aarhus/toiletter-i-aarhus-kommune - // $dataUrl = 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter'; $data = $httpClient->request(Request::METHOD_GET, $dataUrl)->toArray(); $features = $data['features'] ?? null; $sourceProjection = $data['crs']['properties']['name'] ?? null; @@ -53,11 +50,6 @@ public function __invoke( 'type' => $entityType, 'id' => sprintf('%s:%04d', $entityType, $index), ]; -// foreach (['geometry'] as $key) { -// if (array_key_exists($key, $feature)) { -// $entity[self::RESERVED_PROPERTY_NAME_PREFIX.$key] = $feature[$key]; -// } -// } foreach ($feature['properties'] as $name => $value) { if (null === $value) { continue; diff --git a/src/Controller/DataController.php b/src/Controller/DataController.php index 38766ad..25ccb2f 100644 --- a/src/Controller/DataController.php +++ b/src/Controller/DataController.php @@ -26,8 +26,8 @@ final class DataController extends AbstractController ], defaults: ['_format' => self::FORMAT_JSON], )] - public function index(Request $request, string $path, string $_format, - HttpClientInterface $brokerClient, + public function index(Request $request, string $path, string $_format, + HttpClientInterface $brokerClient, ): JsonResponse { $path = '/'.ltrim($path, '/'); $headers = $request->headers->all(); diff --git a/src/GeometryHelper.php b/src/GeometryHelper.php index 05006c4..2af9f14 100644 --- a/src/GeometryHelper.php +++ b/src/GeometryHelper.php @@ -56,7 +56,7 @@ public function transformGeoJsonGeometry(array $geometry, Crs|string|null $from, { $arrayIterator = new \RecursiveArrayIterator($geometry); $iterator = new \RecursiveIteratorIterator($arrayIterator, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($iterator as $key => $value) { + foreach ($iterator as $value) { if (is_array($value) && array_is_list($value) && 2 === count($value) && is_float($value[0]) && is_float($value[1])) { $transformed = $this->transformCoordinates($value[0], $value[1], $to, $from); // Am I stupid or is PHP stupid? diff --git a/templates/default/hundeskov.html.twig b/templates/default/hundeskov.html.twig index 611eb6b..32e3e34 100644 --- a/templates/default/hundeskov.html.twig +++ b/templates/default/hundeskov.html.twig @@ -50,7 +50,7 @@ debug: true, }, #} { - "features_host": {{ path('app_data', {path: '/ngsi-ld/v1/entities', _format: 'geojson', 'type': 'hundeskov', limit: 10})|json_encode|raw}}, + "features_host": {{ path('app_data', {path: '/ngsi-ld/v1/entities', _format: 'geojson', type: 'hundeskov', limit: 10})|json_encode|raw }}, // "features_template": "{ 'type': 'Feature', 'properties': <%= properties %>, 'geometry': <%= _formatJSON(location) %>}", @@ -75,7 +75,7 @@ debug: true, }, { - "features_host": {{ path('app_data', {path: '/ngsi-ld/v1/entities', _format: 'geojson', 'type': 'hundeskov', offset: 10, limit: 10})|json_encode|raw}}, + "features_host": {{ path('app_data', {path: '/ngsi-ld/v1/entities', _format: 'geojson', type: 'hundeskov', offset: 10, limit: 10})|json_encode|raw }}, // type: "json", // features_dataPath: "features", // features_template_row: "features", diff --git a/templates/default/index.html.twig b/templates/default/index.html.twig index af084f0..248b9b6 100644 --- a/templates/default/index.html.twig +++ b/templates/default/index.html.twig @@ -2,9 +2,8 @@ {% block content %} {% for path in ['hundeskov'] %} - {{ path }} - {% endfor %} - + {{ path }} + {% endfor %}
#map
@@ -53,11 +52,10 @@ Scorpio complains about "Bad Request Data." and says "Georelation is not valid" Reset
-{##} +{# #} {# https://github.com/Septima/widget-issues/issues/24#issuecomment-5478400091 #} https://enter.ddev.site:33001/data/ngsi-ld/v1/entities.geojson?georel=near;maxDistance==2000&geometry=Point&coordinates=[8,40] - {% endblock %} diff --git a/tests/GeometryHelperTest.php b/tests/Unit/GeometryHelperTest.php similarity index 88% rename from tests/GeometryHelperTest.php rename to tests/Unit/GeometryHelperTest.php index 000e9dc..5aa9455 100644 --- a/tests/GeometryHelperTest.php +++ b/tests/Unit/GeometryHelperTest.php @@ -1,6 +1,6 @@ transformCoordinates($input[0], $input[1], GeometryHelper::EPSG_GEO_JSON, GeometryHelper::EPSG_25832); + $actual = $helper->transformCoordinates($input[0], $input[1], GeometryHelper\Crs::geoJson(), GeometryHelper\Crs::EPSG_25832); $this->assertEquals($expected, $actual); } @@ -30,7 +30,7 @@ public function testTransformPoint(): void $expected = new Point( 10.236808, 56.101227, ); - $actual = $helper->transformPoint($input, GeometryHelper::EPSG_GEO_JSON, GeometryHelper::EPSG_25832); + $actual = $helper->transformPoint($input, GeometryHelper\Crs::geoJson(), GeometryHelper\Crs::EPSG_25832); $this->assertEquals($expected, $actual); } @@ -55,7 +55,7 @@ public function testTransformGeoJsonGeometry(): void ], ], ]; - $actual = $helper->transformGeoJsonGeometry($input, from: GeometryHelper::EPSG_25832, to: GeometryHelper::EPSG_GEO_JSON); + $actual = $helper->transformGeoJsonGeometry($input, from: GeometryHelper\Crs::EPSG_25832, to: GeometryHelper\Crs::EPSG_4326); $this->assertSame($expected, $actual); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 47a5855..b466cbe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,7 +5,7 @@ require dirname(__DIR__).'/vendor/autoload.php'; if (method_exists(Dotenv::class, 'bootEnv')) { - (new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); + new Dotenv()->bootEnv(dirname(__DIR__).'/.env'); } if ($_SERVER['APP_DEBUG']) { From 9f59e7e8d1654bde06ce8e8a11621393d9ba0561 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Tue, 1 Sep 2026 12:17:00 +0200 Subject: [PATCH 05/59] Added server setup --- .gitignore | 1 + docker-compose.server.override.yml | 36 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 docker-compose.server.override.yml diff --git a/.gitignore b/.gitignore index fbd3470..c7fb02c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .ddev/data .idea +*.local.yml ###> symfony/framework-bundle ### /.env.local diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml new file mode 100644 index 0000000..9a3a124 --- /dev/null +++ b/docker-compose.server.override.yml @@ -0,0 +1,36 @@ +networks: + scorpio: + +# https://docs.ddev.com/en/stable/users/extend/custom-docker-services/ +services: + phpfpm: + image: itkdev/php8.5-fpm:alpine + + scorpio: + hostname: scorpio.local + image: scorpiobroker/all-in-one-runner:java-latest + restart: "no" + ports: + - "9090" + environment: + - DBHOST=scorpio-db + depends_on: + - "scorpio-db" + networks: + - app + - scorpio + platform: "linux/amd64" + + scorpio-db: + image: postgis/postgis + environment: + - POSTGRES_USER=ngb + - POSTGRES_PASSWORD=ngb + - POSTGRES_DB=ngb + networks: + - scorpio + volumes: + - ./.docker/data/scorpio-db:/var/lib/postgresql/data + platform: "linux/amd64" + # https://stackoverflow.com/a/58806511 + command: ["postgres", "-c", "log_statement=all"] From ecaba9cdfe3d66169e0a32f5166c980cade258f2 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:20:37 +0200 Subject: [PATCH 06/59] Added ADR folder along with ADRs existing so far --- docs/adr/001-architecture-symfony-docker.md | 87 ++++++++++++ docs/adr/002-publish-to-a-context-broker.md | 104 +++++++++++++++ docs/adr/003-ngsi-ld-representation.md | 70 ++++++++++ docs/adr/004-coordinate-reference-system.md | 112 ++++++++++++++++ .../005-smart-data-models-as-vocabulary.md | 126 ++++++++++++++++++ .../006-onstreetparking-over-parkinggroup.md | 104 +++++++++++++++ docs/adr/CLAUDE.md | 78 +++++++++++ docs/adr/README.md | 20 +++ 8 files changed, 701 insertions(+) create mode 100644 docs/adr/001-architecture-symfony-docker.md create mode 100644 docs/adr/002-publish-to-a-context-broker.md create mode 100644 docs/adr/003-ngsi-ld-representation.md create mode 100644 docs/adr/004-coordinate-reference-system.md create mode 100644 docs/adr/005-smart-data-models-as-vocabulary.md create mode 100644 docs/adr/006-onstreetparking-over-parkinggroup.md create mode 100644 docs/adr/CLAUDE.md create mode 100644 docs/adr/README.md diff --git a/docs/adr/001-architecture-symfony-docker.md b/docs/adr/001-architecture-symfony-docker.md new file mode 100644 index 0000000..c0f8708 --- /dev/null +++ b/docs/adr/001-architecture-symfony-docker.md @@ -0,0 +1,87 @@ +# 001: Architecture — Symfony 8 on the ITK Dev Docker template + +| Field | Value | +|--------------------|----------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-24 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, future maintainers | +| **Status** | Draft | + +## Context + +The adapter reads open data sets, converts them to a standard smart-city +representation, and publishes them to a context broker. It needs a runtime, an +HTTP client, a console for running imports, and a local development environment +including a broker to import into. It has no web UI and no domain data of its +own. + +ITK Dev maintains a fleet of PHP services with an established Docker-based +development convention, expressed as versioned project templates with shared CI +and coding-standards configuration. A new application either adopts that or +diverges from it. + +This ADR serves to decide the runtime, framework and development +environment the application is built on. + +### Drivers + +- **Functional:** scheduled console commands; outbound HTTP; a local broker. + No database and no HTTP surface of its own. +- **Non-functional:** minimal onboarding cost; shared tooling rather than + reimplemented tooling; reproducible across developers and CI; long-term + vendor support. + +### Options Considered + +1. **PHP 8.4 / Symfony 8 on the ITK Dev `symfony-8` template.** Matches the + organisation's existing stack, so CI, coding standards and task runner come + for free; the console component suits scheduled imports. Provisions a web + server, database and mail catcher this application never uses, and its PHP + version runs ahead of developer hosts, making containers mandatory. +2. **Minimal framework project without the template, run on the host.** No + unused services, no container requirement for the application — but shared + CI and coding-standards config would be reimplemented by hand, and a local + broker needs containers anyway, so the dependency is moved rather than + removed. +3. **A second entry point in an existing internal application.** One + deployment to operate, but couples a batch importer's release cycle to a + user-facing application and inherits dependencies it has no use for. +4. **A different language ecosystem on a bespoke setup.** Richer geospatial + libraries in some ecosystems, but no internal expertise and no shared + tooling. The transformations needed are available as mature libraries in the + established stack too. + +## Decision + +**PHP 8.4 + Symfony 8** on the ITK Dev `symfony-8` template, as its **own +deployable service**, with a containerised broker overlay for local development. + +- Standardising costs less over the application's lifetime than trimming unused + services. A second toolchain must be learned and patched; idle containers + cost only disk. +- A batch importer and a user-facing application have different lifecycles and + failure modes, so they stay separate services. +- No domain persistence is needed — the broker is the system of record — so the + template's database service is left unused rather than removed, keeping + template updates a clean diff. +- Local development includes a real broker, so imports are verified end to end + rather than only as serialised output. + +## Consequences + +### Positive + +- Onboarding cost close to zero; CI and coding standards work from the first + commit. +- No schema, no migrations, no state to keep consistent with the broker. + +### Negative / Trade-offs + +- **Containers are mandatory.** The template's PHP runs ahead of developer + hosts, so dependency management, console commands and tests cannot run + natively. Most likely source of first-run confusion. +- A web server, database and mail catcher are provisioned and never used. +- Broker images are not published for every CPU architecture, so local start-up + may be slow under emulation. +- The application follows the template's choices; deviating later has a cost. diff --git a/docs/adr/002-publish-to-a-context-broker.md b/docs/adr/002-publish-to-a-context-broker.md new file mode 100644 index 0000000..7372479 --- /dev/null +++ b/docs/adr/002-publish-to-a-context-broker.md @@ -0,0 +1,104 @@ +# 002: Publication mechanism — publish to a context broker + +| Field | Value | +|--------------------|--------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-24 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, data consumers, future maintainers | +| **Status** | Draft | + +## Context + +The adapter makes data available to consumers the organisation does +not control and cannot brief. The data already exists in operational systems, +with heterogeneous formats, coordinate systems and access methods. What has to +be decided is the mechanism by which it is published. The source systems remain +authoritative; the published copy is not a system of record. + +This ADR serves to decide the mechanism by which data is published to +consumers. + +### Drivers + +- **Functional:** query by location and attribute, not bulk download only; + several data sets reaching one consumer-facing surface; change notification; + adding a data set without changing consumer integrations. +- **Non-functional:** interpretable by consumers we have never spoken to; + operational cost proportionate to the data and the number of consumers; + existing client tooling rather than clients we supply; an interface that + outlives individual data sets. + +### Options Considered + +1. **An NGSI-LD context broker.** Provides geospatial and attribute queries, + pagination, subscriptions and a temporal interface without implementing + them; payloads carry a vocabulary reference, so they are self-describing; + many producers converge on one consumer surface. Substantial operational + weight — typically a database and message bus alongside the broker — and the + strictness of a particular implementation is inherited. +2. **Static file export** on a web server or object store. Near-zero + operational cost, trivially cacheable, readable by anything. No query, so + consumers download everything and filter client-side; no change + notification; conventions must be documented in prose because nothing in the + file declares its own meaning. +3. **A bespoke REST API** over our own datastore. Exact fit, full control of + the query surface and semantics. Every capability is ours to build and + maintain — geo-queries, filtering, pagination, notifications, documentation, + clients, versioning — and consumers must learn an interface that exists + nowhere else. +4. **Direct database access or a read replica.** No API layer, powerful ad-hoc + querying. Exposes internal schema as a public contract, requires per-consumer + credentials and network access, and is unusable by browser-based consumers. + +## Decision + +Publish to an **NGSI-LD context broker**. + +- Consumers of geographic data need "everything within this area" and + "everything of this kind" more often than the whole data set. A broker + provides that as a standard interface rather than a per-data-set feature. +- Publishing structure without a vocabulary reference requires every consumer + to have our documentation. A broker payload carries the reference. +- For a single small data set a static export would be cheaper and better. Once + several heterogeneous data sets must be published, the fixed operational cost + is paid once while the per-data-set cost approaches zero, and consumers + integrate once rather than once per source. +- New consumers require no change to the adapter, and new data sets require no + change to consumers. +- Existing viewers, dashboards and connectors speak this interface; a bespoke + API would mean supplying clients indefinitely. + +The broker's value is interoperability and query, not storage. If no consumer +reads the data through its interface, a static export would have been the better +decision. Revisit once data sets have been published long enough for consumers +to appear. + +## Consequences + +### Positive + +- Geospatial and attribute queries, pagination, subscriptions and a temporal + interface, none of which are implemented here. +- Payloads reference a shared vocabulary, so they need no bespoke + documentation. +- Additional data sets reach every existing consumer with no integration work. +- The adapter has no database and no read surface of its own. + +### Negative / Trade-offs + +- **Operational weight out of proportion to a small data set.** A broker + deployment is several services to run, patch, monitor and back up. For a + static data set a file on a web server would serve the same need. +- **Broker implementations impose constraints beyond the standard.** Those + encountered include accepting only one spelling of a UTC timestamp while + rejecting an equivalent one, requiring the vocabulary reference on read + requests — with omission returning an empty success rather than an error — + and collapsing single-element lists to scalars. +- **Vocabulary documents may be fetched over the network during writes**, so + third-party availability becomes part of the import path. +- **No delete semantics.** Upsert creates and updates but never removes, so + records that disappear upstream persist until reconciliation is built. +- JSON-LD is a learning curve for maintainers and consumers. +- Fitting data to a shared vocabulary costs effort that publishing as-is + would not. diff --git a/docs/adr/003-ngsi-ld-representation.md b/docs/adr/003-ngsi-ld-representation.md new file mode 100644 index 0000000..8adcb7e --- /dev/null +++ b/docs/adr/003-ngsi-ld-representation.md @@ -0,0 +1,70 @@ +# 003: NGSI-LD representation — normalized form and batch upsert + +| Field | Value | +|--------------------|--------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-24 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, data consumers, future maintainers | +| **Status** | Draft | + +## Context + +ADR 002 chooses an NGSI-LD context broker. That leaves two representation +choices open: how attributes are shaped, and which write operation is used. + +NGSI v2, the older FIWARE API generation, was not considered viable: it has no +`@context`, so a shared vocabulary cannot be expressed, and the +organisation operates no v2 broker. + +This ADR serves to decide how attributes are shaped and which write +operation is used. + +### Drivers + +- **Functional:** the broker must accept the payload on write; attribute-level + metadata must be expressible; repeated imports must not duplicate entities. +- **Non-functional:** idempotency; payload size; readability for consumers. + +### Options Considered + +#### Attribute form + +1. **Normalized** — every attribute an object naming its kind. Verbose, but + the form brokers accept on write and the only one carrying metadata such as + `observedAt`. +2. **Key-values** — flat `name: value`. Much smaller and easier to read, but + read-only and cannot carry attribute metadata. + +#### Write operation + +1. **Batch upsert** — creates or updates. Idempotent when identifiers are + derived from source keys. +2. **Create** — fails with `409` for identifiers that already exist, so a + re-import errors rather than refreshing. +3. **Batch replace** — silently drops attributes absent from the payload, + making partial payloads destructive. + +## Decision + +Publish **normalized** NGSI-LD with `Content-Type: application/ld+json`, via +**batch upsert**. + +- Normalized is the only form accepted on write, so key-values is not a real + option for a producer. +- Upsert makes imports idempotent: identifiers derive from each source's + primary key, so re-running updates in place. + +## Consequences + +### Positive + +- Re-imports produce no duplicates and need no prior state. +- Attribute metadata remains available if a source ever supplies it. + +### Negative / Trade-offs + +- Payloads are considerably larger than key-values. +- Upsert never deletes, so records removed upstream persist until + reconciliation is built (see ADR 002). +- Consumers unfamiliar with JSON-LD face a learning curve. diff --git a/docs/adr/004-coordinate-reference-system.md b/docs/adr/004-coordinate-reference-system.md new file mode 100644 index 0000000..177d6b7 --- /dev/null +++ b/docs/adr/004-coordinate-reference-system.md @@ -0,0 +1,112 @@ +# 004: Coordinate reference system — publish WGS84 (EPSG:4326) + +| Field | Value | +|--------------------|----------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-27 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, broker consumers, future maintainers | +| **Status** | Draft | + +## Context + +Every entity the adapter publishes carries a `location` GeoProperty, so the +coordinate reference system is a cross-cutting concern rather than a per-source +detail. + +Input data arrives in whatever CRS its publisher uses. Danish municipal data is +commonly projected — typically EPSG:25832 (ETRS89 / UTM zone 32N), as eastings +and northings in metres. Other inputs may already be geographic, or use a +different projection. The adapter cannot assume one input CRS. + +Projected coordinates are sometimes delivered inside a GeoJSON envelope, which +states a geometry type but not units. The data models specify only that +`location` is GeoJSON and make no reference to a coordinate system; the +constraint comes from GeoJSON itself. + +This ADR serves to decide which coordinate reference system is published, +and at what precision. + +### Drivers + +- **Functional:** consumers must be able to interpret `location` unbriefed; + geo-queries must return correct results; clients must render without + preprocessing; one rule must hold for every input. +- **Non-functional:** self-description; conformance; uniformity across inputs; + precision no worse than the input. + +### Options Considered + +1. **Normalise everything to WGS84, reprojecting in the adapter.** Conforms to + RFC 7946; self-describing; geo-queries work; one rule however many input + CRSs accumulate. Requires a reprojection dependency, and each input must + declare its CRS. +2. **Pass each input's native CRS through unchanged.** No transformation, no + dependency — but produces invalid GeoJSON with nowhere to declare the CRS, + makes entities from different inputs mutually incomparable, and breaks + geo-queries because distances are read as degrees. Every failure is silent. +3. **Publish WGS84 and also retain original coordinates in an extra + attribute.** Avoids a round trip for consumers wanting native coordinates, + but the attribute cannot have a stable shape: each input brings its own CRS + and geometry type, and it would be absent for inputs already in WGS84. A + consumer cannot code against that, so it would go unused. +4. **Pass native CRSs through under RFC 7946's "prior arrangement" clause, + documenting each out of band.** Permitted by the RFC, but the clause + requires all parties to have agreed — incompatible with a broker whose + consumers are unknown by design. + +## Decision + +Every `location` the adapter emits is **EPSG:4326 (WGS84) +longitude/latitude**, at **full precision — coordinates are not rounded**. Each +input declares its own CRS; reprojection happens at the boundary between reading +an input and building an entity, and nowhere else. + +- **The specification is unambiguous.** RFC 7946 §4: "The coordinate reference + system for all GeoJSON coordinates is a geographic coordinate reference + system, using the World Geodetic System 1984 (WGS 84) datum, with longitude + and latitude units of decimal degrees." NGSI-LD GeoProperty values are + GeoJSON, so the requirement is inherited. +- **There is no way to declare otherwise.** RFC 7946 Appendix B.1: + "Specification of coordinate reference systems has been removed, i.e., the + 'crs' member of [GJ2008] is no longer used." Publishing projected coordinates + means publishing an undeclarable assumption. Inputs may still carry that + deprecated member; it can be read, but not passed on. +- Entities from different inputs are queried together, so a query spanning two + inputs published in different CRSs returns meaningless results. +- A broker given projected coordinates accepts them, answers geo-queries + incorrectly, and renders points in the wrong location. No error is raised at + any stage. +- **The conversion is lossless at full precision.** A projected-to-geographic + round trip returns the input exactly when no rounding is applied. Rounding + trades accuracy for a marginal reduction in payload size. +- `source` and `seeAlso` can reference the originating export, which states its + own CRS. A coordinate copied into an extra attribute states nothing. + +## Consequences + +### Positive + +- Payloads are valid GeoJSON and NGSI-LD; geo-queries work and are comparable + across inputs; any client renders them unmodified. +- One rule for every present and future input. +- Reprojection is isolated in one component with its own tests, verified + against independently known reference coordinates, so a regression fails + loudly instead of silently relocating data. + +### Negative / Trade-offs + +- Adds a reprojection dependency. National grid definitions are not always + shipped and may need registering explicitly, making them load-bearing + project code. +- **Datum shifts are approximated.** ETRS89-based grids are treated as + equivalent to WGS84 via a null datum transformation. The two were coincident + in 1989 and have diverged by roughly 0.5–1 m since, at about 2.5 cm per year. + What is published is therefore ETRS89 labelled WGS84. This is standard + practice in web GIS, but it is the largest error in the pipeline — greater + than the source's own positional accuracy — so a consumer using a rigorous + transformation with an explicit epoch will land about a metre away. +- Consumers with natively projected stacks must convert. +- Every new input must declare its CRS, and unsupported ones need adding. +- Only point geometries were implemented initially; line and area geometries + were added later. diff --git a/docs/adr/005-smart-data-models-as-vocabulary.md b/docs/adr/005-smart-data-models-as-vocabulary.md new file mode 100644 index 0000000..42f11ec --- /dev/null +++ b/docs/adr/005-smart-data-models-as-vocabulary.md @@ -0,0 +1,126 @@ +# 005: Vocabulary — adopt Smart Data Models + +| Field | Value | +|--------------------|--------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-31 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, data consumers, future maintainers | +| **Status** | Draft | + +## Context + +NGSI-LD defines how attributes are carried and how to reference a vocabulary. +It does not define entity types or attribute names. Without a vocabulary the +JSON-LD context resolves to nothing, entity types are local strings, and +consumers still need our documentation to interpret anything. + +The choice also determines the cost of onboarding each data set, since mapping +a source onto an existing model takes more effort than exposing its fields +verbatim. + +This ADR serves to decide which vocabulary supplies entity types and +attribute names, and the rules for mapping sources onto it. + +### Drivers + +- **Functional:** types and attributes interpretable without our + documentation; expressible as a JSON-LD context a broker can resolve; + coverage across the domains in scope. +- **Non-functional:** a vocabulary consumers plausibly already know; governed + and maintained by someone else; mapping cost that does not dominate + onboarding. + +### Options Considered + +1. **Smart Data Models.** Purpose-built for NGSI-LD and the reference + vocabulary of that ecosystem; publishes JSON-LD context documents per + domain; broad coverage; each model ships a JSON schema and examples, giving + an objective conformance target; open governance. Model depth varies; + many models assume real-time sensing, so static inventory leaves attributes + unset; required attributes occasionally presuppose a hierarchy the source + lacks; enum spellings sometimes disagree between a model's schema and its + examples; versioning is loose. +2. **A vocabulary of our own, with self-hosted context documents.** Exact fit, + no required attributes we cannot satisfy, full control of naming and + versioning. Nobody else speaks it, so consumers return to reading our + documentation; governance, documentation and versioning become ours + indefinitely; no existing tooling recognises the types. +3. **A general-purpose web vocabulary.** Widely recognised, stable governance, + adequate for names, addresses and descriptions. No NGSI-LD conventions for + geometry or relationships, and no domain-specific terms, so the domains in + scope would remain unmodelled. + +## Decision + +Adopt **Smart Data Models**, referencing the relevant domain context documents +alongside the NGSI-LD core context. + +Two rules follow, and they matter more than the choice itself: + +1. **Use an existing model; do not invent a type.** An imperfect standard type + is more useful to a consumer than a perfect private one. +2. **Never fabricate a value to satisfy a model.** An absent attribute is + honest; a fabricated one is indistinguishable from a measured one. Applied: + + - Attributes the source cannot fill are left unset, not approximated, + defaulted or inferred. + - Where a model *requires* an attribute the source cannot supply, choose a + different model rather than inventing the value. Required relationships + are the common case: inventing a related entity yields something + schema-valid and factually wrong that must then be maintained + indefinitely. A sibling model without the requirement is the better + choice even if its terms are less precise. + - In a hierarchy — a root site, subdivisions beneath it, individual units + beneath those, each lower level requiring a relationship upward — publish + at the highest level the source can populate. Static inventory typically + describes a location and a count of units without describing what the + location is part of, so the site level is usually correct. + +The concrete model chosen for a given data set is recorded in its own ADR; this +one states policy only. + +Rationale: + +- The context must resolve to terms a consumer recognises, or publishing gains + nothing over a file. +- Smart Data Models is the vocabulary the surrounding ecosystem uses and ships + the context documents needed to reference it, so adoption is a URL rather + than a project. +- Shipped schemas and examples make modelling disagreements checkable against a + specification instead of settled by preference. +- Mandatory relationships propagate downward: choosing a subdivision level + schedules the need for a parent rather than avoiding it, because the + individual-unit level requires a site as well. +- The costs are asymmetric. Publishing at site level and later finding real + sites exist means a one-off migration. Publishing at subdivision level and + never acquiring real sites means maintaining an invented entity + indefinitely, with every consumer that follows the relationship receiving + something meaningless. + +## Consequences + +### Positive + +- Types and attributes resolve to shared global identifiers. +- Consumers may already have code for the types published. +- Modelling decisions have an external reference point. +- Later data sets are likely already covered, so onboarding does not start with + vocabulary design. +- Published entities are self-contained, with nothing invented to keep in sync. +- Finer granularity can be added later beneath what is already published. + +### Negative / Trade-offs + +- **Many attributes will always be empty.** Models built around real-time + sensing carry availability, occupancy and detection attributes that static + inventory cannot fill. +- **Model choice is embedded in entity identifiers.** Changing model later + means deleting and re-publishing rather than updating in place, so selection + deserves attention before a data set is first published. +- **Enum values must be read from the schema, not the examples.** Where the two + disagree the schema is authoritative, and equivalent-looking values differ + between sibling models, so they must not be copied across. +- **Loose versioning.** A model can change without an obvious signal. +- Mapping a source to a model takes longer than exposing its fields verbatim, + and occasionally the fit is poor. diff --git a/docs/adr/006-onstreetparking-over-parkinggroup.md b/docs/adr/006-onstreetparking-over-parkinggroup.md new file mode 100644 index 0000000..8b04272 --- /dev/null +++ b/docs/adr/006-onstreetparking-over-parkinggroup.md @@ -0,0 +1,104 @@ +# 006: Model selection — OnStreetParking over ParkingGroup + +| Field | Value | +|--------------------|--------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-08-31 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, data consumers, future maintainers | +| **Status** | Draft | + +## Context + +The parking domain is organised as a hierarchy: + +```text +OnStreetParking / OffStreetParking site — no parent, requires id, type, location + └── ParkingGroup subdivision — requires refParkingSite + └── ParkingSpot individual unit — requires refParkingSite, status, category +``` + +This decision applies where a data set provides a count of units per location +with a point geometry, no reference to a containing site, and neither per-unit +geometry nor occupancy. + +Entity identifiers embed the type by convention, so the choice must be made +before first publication: changing it afterwards means deleting and +re-publishing. + +This ADR serves to decide which model in the parking hierarchy is published +under those conditions. + +### Drivers + +- **Functional:** every mandatory relationship must point at an entity that + exists; the restriction on who may park must be expressible unambiguously; + finer granularity addable later without restructuring what is published. +- **Non-functional:** nothing invented purely to satisfy a schema; a choice + that is cheap to reverse in preference to one that is not. + +### Options Considered + +1. **`ParkingGroup`, creating the missing parent site.** `category` offers + `onlyDisabled`, which by name states exclusivity, and the model's reference + example for disabled parking sits at this level. But `refParkingSite` is + mandatory and no value is available for it, so a parent must be invented; + one spanning the whole administrative area asserts a false containment, and + its own mandatory geometry would carry no meaning. + `ParkingSpot` also requires a site, so adding per-unit data later would force + the invented entity into existence after entities had been published against + it. +2. **`ParkingGroup`, omitting `refParkingSite`.** Nothing invented, smallest + change — but knowingly non-conformant, and a schema validator flags every + entity. +3. **`OnStreetParking`.** Requires only `id`, `type` and `location`, all of + which are available. It is the entity both `ParkingGroup` and + `ParkingSpot` are required to reference, so finer granularity can be + attached beneath it, and migration down to `ParkingGroup` stays possible if + real site data appears. `category` offers only `forDisabled`, which does not + state exclusivity as plainly. +4. **`ParkingSpot`.** Models an individual unit, but only a count per location + is available, `status` is mandatory with no occupancy data, and a parent site + is required. Rejected outright. + +## Decision + +Publish each record as an **`OnStreetParking`** entity, with +`category: ["forDisabled"]` and no `refParkingSite`. + +- It is the only option that invents nothing; everything the model requires is + available. +- Mandatory relationships propagate downward, so choosing `ParkingGroup` would + schedule the invented parent rather than avoid it — `ParkingSpot` requires a + site too. +- The costs are asymmetric. Site level, then finding real sites exist, is a + one-off migration. Subdivision level, then never acquiring real sites, means + maintaining an invented entity indefinitely. + +The model's reference example does use `ParkingGroup` for disabled parking, but +points at a real street-address site. It shows what to do when a site exists, +not when none does. + +## Consequences + +### Positive + +- No dangling relationship; every entity is self-contained. +- Nothing invented to create, document or keep in sync. +- Conformant to the model's schema without exceptions. +- `ParkingGroup` or `ParkingSpot` entities can be attached beneath these later + without changing them. + +### Negative / Trade-offs + +- **`forDisabled` does not state exclusivity.** The schema documents `category` + only as "Street parking category" with an enum list and defines no individual + value. The two models' enums use inconsistent prefixes for what appear to be + the same concepts — `forDisabled` / `forResidents` against `onlyDisabled` / + `onlyResidents` — while both carry `onlyWithPermit`, so the spelling cannot + be relied on to carry exclusivity. +- Values must not be copied between the two models' `category` enums. +- Entity type is embedded in identifiers, so any later change means deleting + and re-publishing. +- Domain experts may find a single address described as a "site" + counter-intuitive. diff --git a/docs/adr/CLAUDE.md b/docs/adr/CLAUDE.md new file mode 100644 index 0000000..679a5cd --- /dev/null +++ b/docs/adr/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md — writing ADRs in this project + +Guidance for Claude Code when creating or editing files in `docs/adr/`. + +An ADR here **decides a specific choice on general grounds**. The choice is +concrete; the reasoning must hold beyond any one data set, developer or machine. + +## Never include + +- **Local project or repository names.** These are organisation-level documents; + someone's working copies have no place in them. +- **Specific data sources, their fields, or their quirks.** Not field names, not + envelope shapes, not "the source provides…". State the *condition* the + decision applies under instead: "This decision applies where a data set + provides a count of units per location…". +- **Measurements or counts taken from a data set.** No record counts, no + "measured across N records", no byte or centimetre figures derived from one + export. Standards identifiers (EPSG codes, RFC numbers, framework versions) + and general domain facts are fine. +- **Educational or self-referential framing.** No "two things make this easy to + get wrong", no "note that", no "the real lesson is", no bolded aphorism + followed by the actual fact, and no commentary on the ADR's own importance + ("this is the hardest to reverse"). State the fact and stop. + +## Structure + +```text +# NNN: Area — the choice + +| Field | Value | Created By, Date, Decision Maker, Stakeholders, Status + +## Context the situation; what is undecided + ends with: "This ADR serves to decide …" +### Drivers Functional / Non-functional, brief +### Options Considered one paragraph each, upsides then downsides +## Decision the choice, then terse rationale bullets +## Consequences +### Positive +### Negative / Trade-offs the honest costs, including ones found by hitting them +``` + +The Context section **must end with a single "This ADR serves to …" sentence** +so the purpose is unmissable. + +Status is `Draft` while it needs review, then `Accepted`. Other values: +`Rejected`, `Deprecated by NNN`, `Supersedes NNN`. + +## Numbering + +Numbering follows **dependency order**: every ADR may reference only +lower-numbered ones. Check for forward references after adding or renumbering. +Dates therefore need not run in the same order as numbers. + +Renumbering is only acceptable while nothing external cites the numbers. Once +cited, supersede instead. + +## Before writing, ask whether it earns its place + +- **Is there a real alternative?** An ADR comparing one viable candidate against + a dead end documents a decision that made itself, and dilutes the ones + recording genuine trade-offs. Reframe it around the decisions that did have + alternatives, or fold it into a neighbour. +- **Would folding it into an existing ADR be better?** Prefer fewer, shorter + documents. Consolidation has been chosen over adding a document more than + once. +- **Are the rules distinct?** One principle restated against several targets is + one rule with sub-points, not several rules. + +## Before finishing + +- Grep for source field names, model names in the general ADRs, project names, + record counts and measured figures. +- Grep for `Note that`, `worth noting`, `is the lesson`, `easy to get wrong`, + `Two things`, `earns its`, `is the point`. +- Check no ADR cites a higher number than its own. +- `task coding-standards:markdown:check` — recurring failures are line length + over 120 (MD013), misaligned table pipes (MD060), and bold used as a heading + (MD036); use `####` for sub-headings inside a section. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..1ce7c5e --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,20 @@ +# Architecture Decision Records + +This directory contains Architecture Decision Records (ADRs) for this project. + +See [adr.github.io](https://adr.github.io/) for background on the format. + +| Number | Title | Status | Date | +| ----------------------------------------------- | ------------------------------------------------------- | -------- | ---------- | +| [001](001-architecture-symfony-docker.md) | Architecture — Symfony 8 on the ITK Dev Docker template | Accepted | 2026-08-24 | +| [002](002-publish-to-a-context-broker.md) | Publication mechanism — publish to a context broker | Accepted | 2026-08-24 | +| [003](003-ngsi-ld-representation.md) | NGSI-LD representation — normalized form, batch upsert | Accepted | 2026-08-24 | +| [004](004-coordinate-reference-system.md) | Coordinate reference system — publish WGS84 | Accepted | 2026-08-27 | +| [005](005-smart-data-models-as-vocabulary.md) | Vocabulary — adopt Smart Data Models | Accepted | 2026-08-31 | +| [006](006-onstreetparking-over-parkinggroup.md) | Model selection — OnStreetParking over ParkingGroup | Accepted | 2026-08-31 | + +Numbering follows dependency order: each ADR relies only on lower-numbered +ones. Dates therefore do not run in the same order as numbers. + +All ADRs state general policy and name no data set. Concrete per-data-set +mappings are recorded in the README and in the source classes themselves. From 9aa1a0bf7bc18375a924a3ac5473def7da1286c2 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:20:49 +0200 Subject: [PATCH 07/59] Updated README --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 95afbea..4052b40 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ We use [DDEV](https://ddev.com/) and [Task](https://taskfile.dev/) for developme ``` shell task site:install +``` ``` shell task site:update @@ -12,6 +13,44 @@ ddev launch Run `task` to see what cool task are available. Running `ddev` can help with other stuff. +## Adapter + +Takes an Aarhus open-data set, converts it to [NGSI-LD], and upserts it into the +context broker. + +``` text +source feed (JSON) + → SourceInterface implementation maps fields, fixes quirks, picks the data model + → NgsiEntity normalized NGSI-LD: Property / GeoProperty / Relationship + → NgsiLdBroker POST /ngsi-ld/v1/entityOperations/upsert + → context broker +``` + +| Class | Responsibility | +| ---------------------------------- | ------------------------------------------------ | +| `App\Source\SourceInterface` | Contract for one input data set | +| `App\Source\HandicapParkingSource` | Disabled parking bays → `OnStreetParking` | +| `App\Source\FeedReader` | Path or URL → decoded JSON | +| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | +| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | +| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | +| `App\Command\ImportCommand` | `app:import` | + +``` shell +task import # list the available sources +task import -- MTM-handicap-parking # import one +task import -- MTM-handicap-parking --dry-run --limit 5 # print the payload instead +task broker:entities -- OnStreetParking 10 # read back what landed +``` + +Adding a data set means adding one `SourceInterface` implementation. It is +discovered through `#[AutoconfigureTag('app.source')]` and shows up as an +`app:import` argument with no further wiring. + +Design decisions are recorded in [docs/adr](docs/adr/README.md). + +[NGSI-LD]: https://www.etsi.org/committee/cim + ## Broker A [Scorpio Broker](https://scorpio.readthedocs.io/) is part of the development setup. From 9dadf6294ec6b36b4ff2cfe03f33ad9a212dd2be Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:21:21 +0200 Subject: [PATCH 08/59] Updated CHANGELOG --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf48d94..2e140da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `app:import` command with `--dry-run` and `--limit`, exposed as `task import`. +- `SourceInterface`: extension point for further data sets, discovered through + `#[AutoconfigureTag('app.source')]`. +- `Wgs84Transformer`: reprojects coordinates from any registered CRS to WGS84, + for a single position or a whole GeoJSON geometry of any type. +- `FeedReader`: reads a feed from a filesystem path or an http(s) URL and decodes + it, without interpreting its shape. +- `NgsiEntity`: builds normalized NGSI-LD entities. +- `NgsiLdBroker`: idempotent batch upsert to an NGSI-LD context broker. +- `task broker:entities` for reading entities back out of the broker. +- Architecture Decision Records under `docs/adr`. +- Added test suite + [Unreleased]: https://github.com/itk-dev/enter From 6de59da0dc51fca7efc660f5a69ca5811c0eae67 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:21:35 +0200 Subject: [PATCH 09/59] Added pointers to .env --- .env | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.env b/.env index 999fa0f..2dfb180 100644 --- a/.env +++ b/.env @@ -32,4 +32,18 @@ DEFAULT_URI=http://localhost ###> app ### APP_BROKER_BASE_URI=http://scorpio.local:9090/ + +# JSON-LD contexts attached to every entity, outermost last so the ETSI core +# context resolves the NGSI-LD terms and the domain context resolves the +# Smart Data Models ones. +ENTER_NGSI_CONTEXT_URLS='https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld,https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld' + +# A single domain context, for the Link header that read requests need. Only +# used by `task broker:entities`, and it must be the context defining the type +# being read — Parking while that is the only model published. +ENTER_NGSI_DOMAIN_CONTEXT=https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld + +# Where the disabled-parking export is read from: the live SpatialMap export. +# Accepts any http(s) URL, or a path relative to the project directory. +ENTER_HANDICAP_PARKING_SOURCE='https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' ###< app ### From a56f14cfc5b217fb124c44b7c458668037d75c7d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:22:05 +0200 Subject: [PATCH 10/59] Added a few commands to Taskfile --- Taskfile.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 49f5e72..3b30260 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,6 +36,23 @@ tasks: test:integration: *test_task test:application: *test_task + import: + desc: 'Import a source into the broker, e.g. task import -- MTM-handicap-parking' + cmd: ddev console app:import {{.CLI_ARGS}} + + # The broker itself needs no start task: Scorpio is a DDEV custom service + # (.ddev/docker-compose.scorpio.yaml), so it comes up with the rest of the + # site. Read requests need the domain context in a Link header, which the + # script supplies; the env values come from the dotenv block above, and are + # passed in explicitly because the container shell does not read .env. + broker:entities: + desc: 'List broker entities of a type, e.g. task broker:entities -- OnStreetParking 10' + cmd: >- + ddev exec sh -c + "APP_BROKER_BASE_URI='$APP_BROKER_BASE_URI' + ENTER_NGSI_DOMAIN_CONTEXT='$ENTER_NGSI_DOMAIN_CONTEXT' + sh task/scripts/broker-entities {{.CLI_ARGS}}" + coding-standards:apply: desc: 'Apply coding standards' cmds: From ff7146dad1bca1536d92a6e47052e71146bda4d6 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:24:04 +0200 Subject: [PATCH 11/59] Added wgs84 transformer class --- src/Geo/Wgs84Transformer.php | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/Geo/Wgs84Transformer.php diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php new file mode 100644 index 0000000..9f64596 --- /dev/null +++ b/src/Geo/Wgs84Transformer.php @@ -0,0 +1,160 @@ + + */ + private const DEFINITIONS = [ + // ETRS89 / UTM zone 32N — most of Denmark. + 'EPSG:25832' => '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', + // ETRS89 / UTM zone 33N — Bornholm and eastwards. + 'EPSG:25833' => '+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', + // Web Mercator, in case an input arrives as tile coordinates. + 'EPSG:3857' => '+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +no_defs', + self::TARGET_SRID => '+proj=longlat +datum=WGS84 +no_defs', + ]; + + private readonly Proj4php $proj4; + + /** @var array */ + private readonly array $definitions; + + /** @var array */ + private array $projections = []; + + /** + * @param array $definitions additional PROJ definitions, keyed by SRID + */ + public function __construct(array $definitions = []) + { + $this->definitions = [...self::DEFINITIONS, ...$definitions]; + + $this->proj4 = new Proj4php(); + foreach ($this->definitions as $srid => $definition) { + $this->proj4->addDef($srid, $definition); + } + } + + /** + * Coordinates are not rounded. Downstream use is unknown and may include + * planning work, so the transformed value is published as computed. + * + * @param string $srid source CRS, e.g. "EPSG:25832" + * + * @return array{float, float} GeoJSON coordinate order: [longitude, latitude] + */ + public function toWgs84(string $srid, float $x, float $y): array + { + if (self::TARGET_SRID === $srid) { + return [$x, $y]; + } + + $transformed = $this->proj4->transform( + $this->projection(self::TARGET_SRID), + new Point($x, $y, $this->projection($srid)) + ); + + return [(float) $transformed->x, (float) $transformed->y]; + } + + /** + * @param string $srid source CRS, e.g. "EPSG:25832" + * + * @return array{type: string, coordinates: array{float, float}} GeoJSON Point + */ + public function point(string $srid, float $x, float $y): array + { + return [ + 'type' => 'Point', + 'coordinates' => $this->toWgs84($srid, $x, $y), + ]; + } + + /** + * Reprojects a whole GeoJSON geometry, whatever its type. + * + * Handles Point, LineString, Polygon, MultiPoint, MultiLineString and + * MultiPolygon — every type an NGSI-LD GeoProperty accepts. GeometryCollection + * is not supported, because it carries `geometries` rather than `coordinates`. + * + * A third ordinate (elevation) is dropped: the inputs are two-dimensional, + * and vertical datums are a separate concern this class does not model. + * + * @param string $srid source CRS, e.g. "EPSG:25832" + * @param array $geometry GeoJSON geometry object + * + * @return array{type: string, coordinates: mixed} + */ + public function geometry(string $srid, array $geometry): array + { + $type = $geometry['type'] ?? null; + + if (!\is_string($type) || !\array_key_exists('coordinates', $geometry)) { + throw new \InvalidArgumentException('Not a GeoJSON geometry: "type" and "coordinates" are both required.'); + } + + return [ + 'type' => $type, + 'coordinates' => $this->transformCoordinates($srid, $geometry['coordinates']), + ]; + } + + /** + * GeoJSON nests coordinates to a depth that depends on the geometry type: + * a bare position for Point, an array of positions for LineString, an array + * of those for Polygon, and so on. Recursing until the first element is + * numeric handles every depth without enumerating the types. + * + * @return array + */ + private function transformCoordinates(string $srid, mixed $coordinates): array + { + if (!\is_array($coordinates) || [] === $coordinates) { + throw new \InvalidArgumentException('GeoJSON coordinates must be a non-empty array.'); + } + + if (is_numeric($coordinates[0] ?? null)) { + if (!is_numeric($coordinates[1] ?? null)) { + throw new \InvalidArgumentException('A GeoJSON position needs at least two ordinates.'); + } + + return $this->toWgs84($srid, (float) $coordinates[0], (float) $coordinates[1]); + } + + return array_map( + fn (mixed $nested): array => $this->transformCoordinates($srid, $nested), + array_values($coordinates) + ); + } + + private function projection(string $srid): Proj + { + if (!isset($this->definitions[$srid])) { + throw new \InvalidArgumentException(\sprintf('Unknown CRS "%s". Register a PROJ definition for it before use. Known: %s.', $srid, implode(', ', array_keys($this->definitions)))); + } + + return $this->projections[$srid] ??= new Proj($srid, $this->proj4); + } +} From 0386a7d2fb69484464b91a81725b4a89b7c0cdac Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:24:38 +0200 Subject: [PATCH 12/59] Added a generic way to retrieve data from broker via terminal --- task/scripts/broker-entities | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100755 task/scripts/broker-entities diff --git a/task/scripts/broker-entities b/task/scripts/broker-entities new file mode 100755 index 0000000..280bb6c --- /dev/null +++ b/task/scripts/broker-entities @@ -0,0 +1,34 @@ +#!/bin/sh +# List entities of a given type from the NGSI-LD broker. +# +# Read requests need a domain context supplied in a Link header; without it the +# broker cannot expand a short type name into the full term the entity was +# written under. The context comes from ENTER_NGSI_DOMAIN_CONTEXT, which must +# be the one defining the type being asked for. +# +# Usage: broker-entities [limit] + +set -eu + +if [ $# -lt 1 ]; then + echo "Usage: broker-entities [limit]" >&2 + echo " NGSI-LD entity type, e.g. OnStreetParking" >&2 + echo " [limit] maximum entities to return (default 100)" >&2 + exit 64 +fi + +TYPE="$1" +LIMIT="${2:-100}" + +RESPONSE=$(curl -sS \ + -H "Link: <${ENTER_NGSI_DOMAIN_CONTEXT}>; rel=\"http://www.w3.org/ns/json-ld#context\"; type=\"application/ld+json\"" \ + "${APP_BROKER_BASE_URI%/}/ngsi-ld/v1/entities?type=${TYPE}&limit=${LIMIT}") + +printf '%s\n' "$RESPONSE" + +# A misspelled type, or one whose context is not the one configured, returns an +# empty list with HTTP 200 — indistinguishable from an empty broker or a failed +# import. Say what was asked for so the difference is visible. +if [ "$(printf '%s' "$RESPONSE" | tr -d '[:space:]')" = "[]" ]; then + printf '\nNo entities of type "%s". A type that does not match, or one defined in a context other than the configured ENTER_NGSI_DOMAIN_CONTEXT, returns an empty list rather than an error — check both before concluding the import failed.\n' "$TYPE" >&2 +fi From 492d105e055f1494d7daa1a356b82600aaaa1a9a Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:32:57 +0200 Subject: [PATCH 13/59] Added feedReader --- src/Source/FeedReader.php | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/Source/FeedReader.php diff --git a/src/Source/FeedReader.php b/src/Source/FeedReader.php new file mode 100644 index 0000000..6f8677c --- /dev/null +++ b/src/Source/FeedReader.php @@ -0,0 +1,58 @@ + the decoded document + * + * @throws \RuntimeException when the location is not an http(s) URL, cannot + * be fetched, does not contain valid JSON, or + * does not decode to an array + */ + public function read(string $location): array + { + if (!str_starts_with($location, 'http://') && !str_starts_with($location, 'https://')) { + throw new \RuntimeException(\sprintf('Feed location must be an http(s) URL, got "%s".', $location)); + } + + $json = $this->fetch($location); + + try { + $decoded = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \RuntimeException(\sprintf('Invalid JSON in "%s": %s', $location, $exception->getMessage()), previous: $exception); + } + + // A JSON document may legally be a scalar. Every feed we consume is a + // list or an object, and a scalar here means the location is wrong + // rather than that the feed is empty. + if (!\is_array($decoded)) { + throw new \RuntimeException(\sprintf('Expected a JSON array or object in "%s", got %s.', $location, get_debug_type($decoded))); + } + + return $decoded; + } + + private function fetch(string $url): string + { + try { + return $this->client->request('GET', $url)->getContent(); + } catch (\Throwable $exception) { + throw new \RuntimeException(\sprintf('Could not fetch "%s": %s', $url, $exception->getMessage()), previous: $exception); + } + } +} From 6a2ebea1610ae03478860810ac0d88babe61954a Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:43:57 +0200 Subject: [PATCH 14/59] Added import command --- src/Command/ImportCommand.php | 158 ++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/Command/ImportCommand.php diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php new file mode 100644 index 0000000..1dab064 --- /dev/null +++ b/src/Command/ImportCommand.php @@ -0,0 +1,158 @@ + $sources + */ + public function __construct( + #[AutowireIterator('app.source')] + private readonly iterable $sources, + private readonly NgsiLdBroker $broker, + #[Autowire(env: 'ENTER_NGSI_CONTEXT_URLS')] + private readonly string $contextUrls, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('source', InputArgument::OPTIONAL, 'Source to import. Omit to list the available sources.') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Print the NGSI-LD payload instead of sending it.') + ->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Import at most this many entities.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $sources = []; + foreach ($this->sources as $source) { + $sources[$source->key()] = $source; + } + + if ([] === $sources) { + $io->error('No data sources are registered.'); + $io->listing([ + 'A source must implement App\Source\SourceInterface.', + 'Implementations are picked up automatically — check the class exists and is not excluded from the container.', + ]); + + return Command::FAILURE; + } + + $key = $input->getArgument('source'); + + if (null === $key) { + // Options only make sense together with a source. Listing the + // sources and exiting successfully would look like an import ran. + if ($input->getOption('dry-run') || null !== $input->getOption('limit')) { + $io->error(\sprintf( + 'No source given. Available: %s.', + implode(', ', array_keys($sources)) + )); + + return Command::INVALID; + } + + $io->section('Available sources'); + $io->listing(array_keys($sources)); + + return Command::SUCCESS; + } + + if (!isset($sources[$key])) { + $io->error(\sprintf('Unknown source "%s". Available: %s.', $key, implode(', ', array_keys($sources)))); + + return Command::INVALID; + } + + $limit = null !== $input->getOption('limit') ? max(1, (int) $input->getOption('limit')) : null; + $contexts = $this->contexts(); + + $payload = []; + foreach ($sources[$key]->entities() as $entity) { + $payload[] = $entity->toArray($contexts); + + if (null !== $limit && \count($payload) >= $limit) { + break; + } + } + + // A source that yields nothing is almost always misconfigured rather + // than genuinely empty, and it fails silently by construction: a + // record skipped for a missing field looks exactly like a feed with no + // records. Fail loudly so it cannot be mistaken for a successful run. + if ([] === $payload) { + $io->error(\sprintf('Source "%s" produced no entities.', $key)); + $io->text( + 'The source ran to completion without raising an exception, so every record was ' + .'discarded by the source\'s own guards rather than failing. Verbosity flags will ' + .'not reveal more: there is no exception to show.' + ); + $io->listing([ + 'Does the configured path or URL point at the intended document?', + 'Does the document match the shape the source expects — envelope, nesting, field names?', + 'Which guard returns early — a missing identifier, or a missing geometry?', + ]); + + return Command::FAILURE; + } + + if ($input->getOption('dry-run')) { + $output->writeln(json_encode($payload, self::JSON_FLAGS)); + $io->note(\sprintf('Dry run: %d entities were not sent.', \count($payload))); + + return Command::SUCCESS; + } + + try { + $status = $this->broker->upsert($payload); + } catch (\Throwable $exception) { + $io->error($exception->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf( + 'Upserted %d entities into %s (HTTP %d).', + \count($payload), + $this->broker->brokerUrl(), + $status + )); + + return Command::SUCCESS; + } + + /** + * @return list + */ + private function contexts(): array + { + return array_values(array_filter(array_map('trim', explode(',', $this->contextUrls)))); + } +} From 1fdbf473fd67ef6e02ad9dd7988ae3f572aaf0a6 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 13:46:27 +0200 Subject: [PATCH 15/59] Added source interface for picking up new source adapters --- src/Source/SourceInterface.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Source/SourceInterface.php diff --git a/src/Source/SourceInterface.php b/src/Source/SourceInterface.php new file mode 100644 index 0000000..978c5ce --- /dev/null +++ b/src/Source/SourceInterface.php @@ -0,0 +1,30 @@ + + */ + public function entities(): iterable; +} From d518d7540e35f341e09804d366430a28878a3595 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:00:51 +0200 Subject: [PATCH 16/59] Added mtm spatialmaps handicap parking source adapter --- src/Source/MtmSpatialMaps/HandicapParking.php | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/Source/MtmSpatialMaps/HandicapParking.php diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php new file mode 100644 index 0000000..62414b5 --- /dev/null +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -0,0 +1,106 @@ +reader->read($this->location)['features'] ?? [] as $feature) { + if (\is_array($feature) && null !== $entity = $this->toEntity($feature)) { + yield $entity; + } + } + } + + /** + * @param array $feature GeoJSON Feature + */ + private function toEntity(array $feature): ?NgsiEntity + { + // A Feature keeps its attributes under `properties` and its geometry + // beside them, so neither is at the feature's top level. + $row = $feature['properties'] ?? null; + $geometry = $feature['geometry'] ?? null; + + if (!\is_array($row) || !\is_array($geometry)) { + return null; + } + + // mi_prinx is the feed's stable primary key. Without it there is no + // way to address the same bay again on the next import, and an upsert + // would create duplicates instead of updating. + $key = $row['mi_prinx'] ?? null; + if (null === $key || '' === $key) { + return null; + } + + $entity = new NgsiEntity( + \sprintf('urn:ngsi-ld:OnStreetParking:aarhus-handicap-%s', $key), + 'OnStreetParking' + ); + + // `forDisabled` rather than ParkingGroup's `onlyDisabled`: the two + // models have separate category enums, so values are not interchangeable. + // `onStreet` is dropped because the entity type already states it. + return $entity + ->property('name', $this->address($row)) + ->property('description', trim((string) ($row['bemrk'] ?? ''))) + ->property('category', ['forDisabled']) + ->property('totalSpotNumber', (int) ($row['invalidepladser'] ?? 0)) + ->property('source', $this->location) + ->geoProperty('location', $this->transformer->geometry(self::SOURCE_SRID, $geometry)); + } + + /** + * @param array $row + */ + private function address(array $row): string + { + return trim(\sprintf( + '%s %s', + trim((string) ($row['vejnavn'] ?? '')), + trim((string) ($row['husnnr'] ?? '')) + )); + } +} From 1cde88ac7f06b6d281c35e69630fe176ce396d1f Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:04:10 +0200 Subject: [PATCH 17/59] Added NgsiEntity for normalized NGSI-LD output --- src/Ngsi/NgsiEntity.php | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/Ngsi/NgsiEntity.php diff --git a/src/Ngsi/NgsiEntity.php b/src/Ngsi/NgsiEntity.php new file mode 100644 index 0000000..6f38391 --- /dev/null +++ b/src/Ngsi/NgsiEntity.php @@ -0,0 +1,81 @@ +> */ + private array $attributes = []; + + public function __construct( + private readonly string $id, + private readonly string $type, + ) { + } + + public function id(): string + { + return $this->id; + } + + /** + * Null and empty-string values are dropped rather than emitted as null, + * because the source data uses "" for "not filled in" and a broker would + * otherwise store the emptiness as a fact. + */ + public function property(string $name, mixed $value, ?string $observedAt = null): self + { + if (null === $value || '' === $value || [] === $value) { + return $this; + } + + $attribute = ['type' => 'Property', 'value' => $value]; + + if (null !== $observedAt) { + $attribute['observedAt'] = $observedAt; + } + + $this->attributes[$name] = $attribute; + + return $this; + } + + /** + * @param array{type: string, coordinates: mixed} $geoJson + */ + public function geoProperty(string $name, array $geoJson): self + { + $this->attributes[$name] = ['type' => 'GeoProperty', 'value' => $geoJson]; + + return $this; + } + + public function relationship(string $name, string $object): self + { + $this->attributes[$name] = ['type' => 'Relationship', 'object' => $object]; + + return $this; + } + + /** + * @param list $contextUrls + * + * @return array + */ + public function toArray(array $contextUrls): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + ...$this->attributes, + '@context' => $contextUrls, + ]; + } +} From 20798f263aed751a8b93af7bfd3861ef1f8c5749 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:05:21 +0200 Subject: [PATCH 18/59] Added NgsiLdBroker for idempotent batch upserts --- src/Broker/NgsiLdBroker.php | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/Broker/NgsiLdBroker.php diff --git a/src/Broker/NgsiLdBroker.php b/src/Broker/NgsiLdBroker.php new file mode 100644 index 0000000..2ce7651 --- /dev/null +++ b/src/Broker/NgsiLdBroker.php @@ -0,0 +1,69 @@ +> $entities + * + * @return int the broker's HTTP status code + */ + public function upsert(array $entities): int + { + if ([] === $entities) { + return 204; + } + + $response = $this->client->request( + 'POST', + rtrim($this->brokerUrl, '/').self::UPSERT_PATH, + [ + 'headers' => ['Content-Type' => self::CONTENT_TYPE], + 'json' => $entities, + ] + ); + + $status = $response->getStatusCode(); + + if ($status >= 400) { + throw new \RuntimeException(\sprintf('Broker rejected the upsert with HTTP %d: %s', $status, $response->getContent(false))); + } + + return $status; + } + + public function brokerUrl(): string + { + return $this->brokerUrl; + } +} From 61f6190d154f4ddbf243d83f86c7d0287f12a53f Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:05:33 +0200 Subject: [PATCH 19/59] Added tests --- tests/Command/ImportCommandTest.php | 148 ++++++++++++++ tests/Geo/Wgs84TransformerTest.php | 182 ++++++++++++++++++ tests/Source/FeedReaderTest.php | 114 +++++++++++ .../MtmSpatialMaps/HandicapParkingTest.php | 129 +++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 tests/Command/ImportCommandTest.php create mode 100644 tests/Geo/Wgs84TransformerTest.php create mode 100644 tests/Source/FeedReaderTest.php create mode 100644 tests/Source/MtmSpatialMaps/HandicapParkingTest.php diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php new file mode 100644 index 0000000..23830aa --- /dev/null +++ b/tests/Command/ImportCommandTest.php @@ -0,0 +1,148 @@ + $sources + */ + private function tester(iterable $sources): CommandTester + { + return new CommandTester(new ImportCommand( + $sources, + new NgsiLdBroker(new MockHttpClient(), 'http://broker.invalid'), + 'https://example.com/context.jsonld', + )); + } + + private function source(string $key, NgsiEntity ...$entities): SourceInterface + { + return new class($key, $entities) implements SourceInterface { + /** @param list $entities */ + public function __construct( + private readonly string $key, + private readonly array $entities, + ) { + } + + public function key(): string + { + return $this->key; + } + + public function entities(): iterable + { + yield from $this->entities; + } + }; + } + + /** + * The important one: a source yielding nothing used to exit successfully + * with a warning, which is indistinguishable from a working import. + */ + public function testItFailsWhenASourceProducesNothing(): void + { + $tester = $this->tester([$this->source('empty-source')]); + + $status = $tester->execute(['source' => 'empty-source', '--dry-run' => true]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('produced no entities', $tester->getDisplay()); + } + + public function testItSuggestsCausesWhenASourceProducesNothing(): void + { + $tester = $this->tester([$this->source('empty-source')]); + $tester->execute(['source' => 'empty-source', '--dry-run' => true]); + + $display = $tester->getDisplay(); + + $this->assertStringContainsString('path or URL', $display); + $this->assertStringContainsString('envelope, nesting, field names', $display); + } + + public function testItFailsWhenNoSourcesAreRegistered(): void + { + $tester = $this->tester([]); + + $status = $tester->execute([]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('No data sources are registered', $tester->getDisplay()); + } + + /** + * Passing --dry-run with no source used to print the source listing and + * exit successfully, silently ignoring the flag. + */ + public function testItRejectsOptionsWithoutASource(): void + { + $tester = $this->tester([$this->source('some-source')]); + + $status = $tester->execute(['--dry-run' => true]); + + $this->assertSame(Command::INVALID, $status); + $this->assertStringContainsString('No source given', $tester->getDisplay()); + } + + public function testItStillListsSourcesWhenCalledBare(): void + { + $tester = $this->tester([$this->source('some-source')]); + + $status = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('some-source', $tester->getDisplay()); + } + + public function testItRejectsAnUnknownSource(): void + { + $tester = $this->tester([$this->source('some-source')]); + + $status = $tester->execute(['source' => 'nope']); + + $this->assertSame(Command::INVALID, $status); + $this->assertStringContainsString('Unknown source "nope"', $tester->getDisplay()); + } + + public function testDryRunPrintsThePayloadAndSendsNothing(): void + { + $entity = (new NgsiEntity('urn:ngsi-ld:Example:1', 'Example')) + ->property('name', 'Example'); + + $tester = $this->tester([$this->source('one-entity', $entity)]); + + $status = $tester->execute(['source' => 'one-entity', '--dry-run' => true]); + $display = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('"urn:ngsi-ld:Example:1"', $display); + $this->assertStringContainsString('1 entities were not sent', $display); + } + + public function testLimitCapsThePayload(): void + { + $entities = []; + foreach (range(1, 5) as $i) { + $entities[] = new NgsiEntity(\sprintf('urn:ngsi-ld:Example:%d', $i), 'Example'); + } + + $tester = $this->tester([$this->source('many', ...$entities)]); + $tester->execute(['source' => 'many', '--dry-run' => true, '--limit' => 2]); + + $this->assertStringContainsString('2 entities were not sent', $tester->getDisplay()); + } +} diff --git a/tests/Geo/Wgs84TransformerTest.php b/tests/Geo/Wgs84TransformerTest.php new file mode 100644 index 0000000..7e3d823 --- /dev/null +++ b/tests/Geo/Wgs84TransformerTest.php @@ -0,0 +1,182 @@ +transformer = new Wgs84Transformer(); + } + + public function testItReprojectsFromUtm32ToWgs84(): void + { + [$longitude, $latitude] = $this->transformer->toWgs84( + self::UTM32, + self::REFERENCE_EASTING, + self::REFERENCE_NORTHING + ); + + $this->assertEqualsWithDelta(10.2152, $longitude, 0.001); + $this->assertEqualsWithDelta(56.1592, $latitude, 0.001); + } + + public function testItReturnsLongitudeLatitudeOrder(): void + { + $point = $this->transformer->point(self::UTM32, 574108.2557507273, 6222343.6199512165); + + $this->assertSame('Point', $point['type']); + + // Longitude first, per GeoJSON. A swapped pair is easy to spot here: + // latitude cannot be 10.19. + $this->assertGreaterThan(9.0, $point['coordinates'][0]); + $this->assertLessThan(11.0, $point['coordinates'][0]); + $this->assertGreaterThan(55.0, $point['coordinates'][1]); + $this->assertLessThan(57.0, $point['coordinates'][1]); + } + + public function testItPassesThroughCoordinatesAlreadyInWgs84(): void + { + $this->assertSame( + [10.199512, 56.149753], + $this->transformer->toWgs84(Wgs84Transformer::TARGET_SRID, 10.199512, 56.149753) + ); + } + + public function testItSupportsAnotherRegisteredCrs(): void + { + // UTM zone 33N: same northing, easting near the zone's central + // meridian, so the result must land east of zone 32's output. + [$longitude] = $this->transformer->toWgs84('EPSG:25833', 500000.0, 6224460.0); + + $this->assertEqualsWithDelta(15.0, $longitude, 0.001); + } + + public function testItRejectsAnUnregisteredCrs(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unknown CRS "EPSG:31700"/'); + + $this->transformer->toWgs84('EPSG:31700', 500000.0, 6224460.0); + } + + public function testItAcceptsAdditionalDefinitions(): void + { + $transformer = new Wgs84Transformer([ + 'EPSG:23032' => '+proj=utm +zone=32 +ellps=intl +units=m +no_defs', + ]); + + [$longitude, $latitude] = $transformer->toWgs84('EPSG:23032', self::REFERENCE_EASTING, self::REFERENCE_NORTHING); + + // Different datum, so not identical to EPSG:25832 — but the same + // corner of the world, which proves the definition was registered. + $this->assertEqualsWithDelta(10.2152, $longitude, 0.01); + $this->assertEqualsWithDelta(56.1592, $latitude, 0.01); + } + + public function testItReprojectsAPointGeometry(): void + { + $geometry = $this->transformer->geometry(self::UTM32, [ + 'type' => 'Point', + 'coordinates' => [self::REFERENCE_EASTING, self::REFERENCE_NORTHING], + ]); + + $this->assertSame('Point', $geometry['type']); + $this->assertEqualsWithDelta(10.2152, $geometry['coordinates'][0], 0.001); + } + + public function testItReprojectsALineString(): void + { + $geometry = $this->transformer->geometry(self::UTM32, [ + 'type' => 'LineString', + 'coordinates' => [ + [574108.2557507273, 6222343.6199512165], + [self::REFERENCE_EASTING, self::REFERENCE_NORTHING], + ], + ]); + + $this->assertSame('LineString', $geometry['type']); + $this->assertCount(2, $geometry['coordinates']); + $this->assertEqualsWithDelta(10.1926, $geometry['coordinates'][0][0], 0.001); + $this->assertEqualsWithDelta(10.2152, $geometry['coordinates'][1][0], 0.001); + } + + public function testItReprojectsAPolygonPreservingNesting(): void + { + $geometry = $this->transformer->geometry(self::UTM32, [ + 'type' => 'Polygon', + 'coordinates' => [ + [ + [574108.0, 6222343.0], + [574208.0, 6222343.0], + [574208.0, 6222443.0], + [574108.0, 6222343.0], + ], + ], + ]); + + $this->assertSame('Polygon', $geometry['type']); + $this->assertCount(1, $geometry['coordinates']); + $this->assertCount(4, $geometry['coordinates'][0]); + $this->assertIsFloat($geometry['coordinates'][0][0][0]); + } + + public function testItReprojectsAMultiPolygon(): void + { + $ring = [ + [574108.0, 6222343.0], + [574208.0, 6222343.0], + [574208.0, 6222443.0], + [574108.0, 6222343.0], + ]; + + $geometry = $this->transformer->geometry(self::UTM32, [ + 'type' => 'MultiPolygon', + 'coordinates' => [[$ring], [$ring]], + ]); + + $this->assertCount(2, $geometry['coordinates']); + $this->assertCount(4, $geometry['coordinates'][1][0]); + } + + public function testItRejectsAGeometryWithoutCoordinates(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->transformer->geometry(self::UTM32, ['type' => 'Point']); + } + + public function testItRejectsAGeometryCollection(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->transformer->geometry(self::UTM32, [ + 'type' => 'GeometryCollection', + 'geometries' => [], + ]); + } + + public function testItRejectsAPositionWithASingleOrdinate(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->transformer->geometry(self::UTM32, ['type' => 'Point', 'coordinates' => [574108.0]]); + } +} diff --git a/tests/Source/FeedReaderTest.php b/tests/Source/FeedReaderTest.php new file mode 100644 index 0000000..d5487f3 --- /dev/null +++ b/tests/Source/FeedReaderTest.php @@ -0,0 +1,114 @@ +reader($client)->read('https://example.com/feed.json'); + + $this->assertSame([['example' => 1]], $document); + } + + public function testItAcceptsABareArrayDocument(): void + { + $client = new MockHttpClient(new MockResponse('[1, 2, 3]')); + + $this->assertSame([1, 2, 3], $this->reader($client)->read('http://example.com/feed.json')); + } + + /** + * The envelope is feed-specific knowledge, so it must survive intact for + * the caller to interpret. Returning the features list directly would push + * that knowledge into the wrong class. + */ + public function testItDoesNotUnwrapTheEnvelope(): void + { + $client = new MockHttpClient(new MockResponse(self::FEATURE_COLLECTION)); + + $document = $this->reader($client)->read('https://example.com/feed.json'); + + $this->assertArrayHasKey('features', $document); + $this->assertArrayNotHasKey(0, $document); + $this->assertCount(2, $document['features']); + } + + public function testItRejectsALocationThatIsNotAUrl(): void + { + $client = new MockHttpClient(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/must be an http\(s\) URL/'); + + try { + $this->reader($client)->read('data/feed.json'); + } finally { + // Rejection has to happen before the request, or a mistyped + // location becomes an opaque transport error instead. + $this->assertSame(0, $client->getRequestsCount()); + } + } + + /** + * A string that merely begins with "http" is not a URL. + */ + public function testItRejectsAPathThatMerelyStartsWithHttp(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/must be an http\(s\) URL/'); + + $this->reader()->read('https-export.json'); + } + + public function testItFailsOnInvalidJson(): void + { + $client = new MockHttpClient(new MockResponse('{ not json')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Invalid JSON/'); + + $this->reader($client)->read('https://example.com/feed.json'); + } + + public function testItFailsWhenTheDocumentIsAScalar(): void + { + $client = new MockHttpClient(new MockResponse('"just a string"')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Expected a JSON array or object/'); + + $this->reader($client)->read('https://example.com/feed.json'); + } + + public function testItWrapsTransportFailures(): void + { + $client = new MockHttpClient(new MockResponse('', ['http_code' => 500])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Could not fetch/'); + + $this->reader($client)->read('https://example.com/feed.json'); + } +} diff --git a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php new file mode 100644 index 0000000..88fc89b --- /dev/null +++ b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php @@ -0,0 +1,129 @@ +> */ + private array $entities; + + protected function setUp(): void + { + $source = new HandicapParking( + new Wgs84Transformer(), + // Never used: the fixture is read from disk, not over HTTP. + new MockHttpClient(), + 'data/handicapparkering.json', + self::PARKING_SITE_URN, + \dirname(__DIR__, 2), + ); + + $this->entities = array_map( + static fn (NgsiEntity $entity): array => $entity->toArray(['https://example.com/context.jsonld']), + iterator_to_array($source->entities(), false) + ); + } + + public function testItReadsEveryRecordInTheFixture(): void + { + $this->assertCount(10, $this->entities); + } + + public function testItBuildsAParkingGroupWithAStableId(): void + { + $first = $this->entities[0]; + + // The id is derived from mi_prinx so that re-importing upserts the + // same entity instead of creating a duplicate. + $this->assertSame('urn:ngsi-ld:ParkingGroup:aarhus-handicap-261', $first['id']); + $this->assertSame('ParkingGroup', $first['type']); + } + + public function testItJoinsStreetAndHouseNumberIntoName(): void + { + $this->assertSame('P.P. Ørums Gade 2', $this->entities[0]['name']['value']); + } + + public function testItOmitsTheHouseNumberWhenBlank(): void + { + $brammersgade = $this->entityById('urn:ngsi-ld:ParkingGroup:aarhus-handicap-378'); + + // husnnr is "" for this row, so the name must not end in a space. + $this->assertSame('Brammersgade', $brammersgade['name']['value']); + } + + public function testItMarksEveryGroupAsOnStreetDisabledParking(): void + { + foreach ($this->entities as $entity) { + $this->assertSame(['onStreet', 'onlyDisabled'], $entity['category']['value']); + } + } + + public function testItCarriesTheBayCountAsTotalSpotNumber(): void + { + $this->assertSame(2, $this->entities[0]['totalSpotNumber']['value']); + $this->assertSame(1, $this->entities[1]['totalSpotNumber']['value']); + } + + /** + * The feed stores dates as Excel serial numbers with a decimal comma + * ("43543,6190690972"), which has to become a real timestamp. + */ + public function testItConvertsExcelSerialDatesToIso8601(): void + { + $observedAt = $this->entities[0]['totalSpotNumber']['observedAt']; + + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $observedAt); + $this->assertStringStartsWith('2019-03-', $observedAt); + } + + public function testItEmitsLocationAsAGeoProperty(): void + { + $location = $this->entities[0]['location']; + + $this->assertSame('GeoProperty', $location['type']); + $this->assertSame('Point', $location['value']['type']); + } + + public function testItDropsEmptyDescriptions(): void + { + // bemrk is "" for the first row and "Navitas" for mi_prinx 215. + $this->assertArrayNotHasKey('description', $this->entities[0]); + $this->assertSame( + 'Navitas', + $this->entityById('urn:ngsi-ld:ParkingGroup:aarhus-handicap-215')['description']['value'] + ); + } + + public function testItRelatesEveryGroupToTheSyntheticParkingSite(): void + { + foreach ($this->entities as $entity) { + $this->assertSame('Relationship', $entity['refParkingSite']['type']); + $this->assertSame(self::PARKING_SITE_URN, $entity['refParkingSite']['object']); + } + } + + /** + * @return array + */ + private function entityById(string $id): array + { + foreach ($this->entities as $entity) { + if ($id === $entity['id']) { + return $entity; + } + } + + $this->fail(\sprintf('No entity with id "%s".', $id)); + } +} From db7575b30682875ccc40032ac28995628efde362 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:06:09 +0200 Subject: [PATCH 20/59] Renaming paths to new source location structure --- .env | 4 ++-- README.md | 22 +++++++++++----------- Taskfile.yml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.env b/.env index 2dfb180..5e53eba 100644 --- a/.env +++ b/.env @@ -44,6 +44,6 @@ ENTER_NGSI_CONTEXT_URLS='https://raw.githubusercontent.com/smart-data-models/dat ENTER_NGSI_DOMAIN_CONTEXT=https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld # Where the disabled-parking export is read from: the live SpatialMap export. -# Accepts any http(s) URL, or a path relative to the project directory. -ENTER_HANDICAP_PARKING_SOURCE='https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' +# Must be an http(s) URL — feeds are read where they live, never from a copy. +ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE='https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' ###< app ### diff --git a/README.md b/README.md index 4052b40..b6a8d5f 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,20 @@ source feed (JSON) → context broker ``` -| Class | Responsibility | -| ---------------------------------- | ------------------------------------------------ | -| `App\Source\SourceInterface` | Contract for one input data set | -| `App\Source\HandicapParkingSource` | Disabled parking bays → `OnStreetParking` | -| `App\Source\FeedReader` | Path or URL → decoded JSON | -| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | -| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | -| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | -| `App\Command\ImportCommand` | `app:import` | +| Class | Responsibility | +| ------------------------------------------- | ------------------------------------------------ | +| `App\Source\SourceInterface` | Contract for one input data set | +| `App\Source\FeedReader` | Feed URL → decoded JSON | +| `App\Source\MtmSpatialMaps\HandicapParking` | Disabled parking bays → `OnStreetParking` | +| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | +| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | +| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | +| `App\Command\ImportCommand` | `app:import` | ``` shell task import # list the available sources -task import -- MTM-handicap-parking # import one -task import -- MTM-handicap-parking --dry-run --limit 5 # print the payload instead +task import -- mtm_spatialmaps-handicap-parking # import one +task import -- mtm_spatialmaps-handicap-parking --dry-run --limit 5 # print the payload instead task broker:entities -- OnStreetParking 10 # read back what landed ``` diff --git a/Taskfile.yml b/Taskfile.yml index 3b30260..6ac1694 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -37,7 +37,7 @@ tasks: test:application: *test_task import: - desc: 'Import a source into the broker, e.g. task import -- MTM-handicap-parking' + desc: 'Import a source into the broker, e.g. task import -- mtm_spatialmaps-handicap-parking' cmd: ddev console app:import {{.CLI_ARGS}} # The broker itself needs no start task: Scorpio is a DDEV custom service From 8e4f7ca049ed80907c4046190acd1cef0bb9f4d3 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:07:15 +0200 Subject: [PATCH 21/59] Added pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e39b258 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ + Date: Tue, 1 Sep 2026 14:23:32 +0200 Subject: [PATCH 22/59] Coding standards --- src/Broker/NgsiLdBroker.php | 10 +++++----- src/Command/ImportCommand.php | 3 +-- src/Geo/Wgs84Transformer.php | 4 ++-- src/Source/FeedReader.php | 4 ++-- src/Source/MtmSpatialMaps/HandicapParking.php | 10 +++++----- tests/Command/ImportCommandTest.php | 8 ++++---- tests/Geo/Wgs84TransformerTest.php | 6 +++--- tests/Source/FeedReaderTest.php | 2 +- tests/Source/MtmSpatialMaps/HandicapParkingTest.php | 2 +- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/Broker/NgsiLdBroker.php b/src/Broker/NgsiLdBroker.php index 2ce7651..806a99f 100644 --- a/src/Broker/NgsiLdBroker.php +++ b/src/Broker/NgsiLdBroker.php @@ -15,21 +15,21 @@ * import idempotent, which matters because the entity ids are derived from * the source's own primary key. */ -final class NgsiLdBroker +final readonly class NgsiLdBroker { - private const UPSERT_PATH = '/ngsi-ld/v1/entityOperations/upsert'; + private const string UPSERT_PATH = '/ngsi-ld/v1/entityOperations/upsert'; /** * The payload carries its own @context, so it must be sent as * application/ld+json. Sending application/json instead requires the * context in a Link header, and brokers reject the mismatch. */ - private const CONTENT_TYPE = 'application/ld+json'; + private const string CONTENT_TYPE = 'application/ld+json'; public function __construct( - private readonly HttpClientInterface $client, + private HttpClientInterface $client, #[Autowire(env: 'APP_BROKER_BASE_URI')] - private readonly string $brokerUrl, + private string $brokerUrl, ) { } diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php index 1dab064..b0c6a46 100644 --- a/src/Command/ImportCommand.php +++ b/src/Command/ImportCommand.php @@ -6,7 +6,6 @@ use App\Broker\NgsiLdBroker; use App\Source\SourceInterface; -use MongoDB\Driver\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -153,6 +152,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ private function contexts(): array { - return array_values(array_filter(array_map('trim', explode(',', $this->contextUrls)))); + return array_values(array_filter(array_map(trim(...), explode(',', $this->contextUrls)))); } } diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php index 9f64596..4894555 100644 --- a/src/Geo/Wgs84Transformer.php +++ b/src/Geo/Wgs84Transformer.php @@ -15,7 +15,7 @@ */ final class Wgs84Transformer { - public const TARGET_SRID = 'EPSG:4326'; + public const string TARGET_SRID = 'EPSG:4326'; /** * PROJ definitions for coordinate systems this application reads. @@ -26,7 +26,7 @@ final class Wgs84Transformer * * @var array */ - private const DEFINITIONS = [ + private const array DEFINITIONS = [ // ETRS89 / UTM zone 32N — most of Denmark. 'EPSG:25832' => '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', // ETRS89 / UTM zone 33N — Bornholm and eastwards. diff --git a/src/Source/FeedReader.php b/src/Source/FeedReader.php index 6f8677c..6a9c280 100644 --- a/src/Source/FeedReader.php +++ b/src/Source/FeedReader.php @@ -9,10 +9,10 @@ /** * Fetches a feed over HTTP and decodes it. */ -final class FeedReader +final readonly class FeedReader { public function __construct( - private readonly HttpClientInterface $client, + private HttpClientInterface $client, ) { } diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 62414b5..296340b 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -21,18 +21,18 @@ * * @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking */ -final class HandicapParking implements SourceInterface +final readonly class HandicapParking implements SourceInterface { /** * The CRS(coordinate reference system) this feed publishes - SRID (Spatial reference identifier). */ - private const SOURCE_SRID = 'EPSG:25832'; + private const string SOURCE_SRID = 'EPSG:25832'; public function __construct( - private readonly FeedReader $reader, - private readonly Wgs84Transformer $transformer, + private FeedReader $reader, + private Wgs84Transformer $transformer, #[Autowire(env: 'ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE')] - private readonly string $location, + private string $location, ) { } diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php index 23830aa..ae858a3 100644 --- a/tests/Command/ImportCommandTest.php +++ b/tests/Command/ImportCommandTest.php @@ -29,11 +29,11 @@ private function tester(iterable $sources): CommandTester private function source(string $key, NgsiEntity ...$entities): SourceInterface { - return new class($key, $entities) implements SourceInterface { + return new readonly class($key, $entities) implements SourceInterface { /** @param list $entities */ public function __construct( - private readonly string $key, - private readonly array $entities, + private string $key, + private array $entities, ) { } @@ -120,7 +120,7 @@ public function testItRejectsAnUnknownSource(): void public function testDryRunPrintsThePayloadAndSendsNothing(): void { - $entity = (new NgsiEntity('urn:ngsi-ld:Example:1', 'Example')) + $entity = new NgsiEntity('urn:ngsi-ld:Example:1', 'Example') ->property('name', 'Example'); $tester = $this->tester([$this->source('one-entity', $entity)]); diff --git a/tests/Geo/Wgs84TransformerTest.php b/tests/Geo/Wgs84TransformerTest.php index 7e3d823..aacf039 100644 --- a/tests/Geo/Wgs84TransformerTest.php +++ b/tests/Geo/Wgs84TransformerTest.php @@ -9,15 +9,15 @@ class Wgs84TransformerTest extends TestCase { - private const UTM32 = 'EPSG:25832'; + private const string UTM32 = 'EPSG:25832'; /** * Reference coordinates for a building on the Aarhus harbourfront, known * independently to sit at roughly 56.1592 N, 10.2152 E. This is the * ground truth that catches a broken or missing CRS definition. */ - private const REFERENCE_EASTING = 575477.5441628407; - private const REFERENCE_NORTHING = 6224460.129141892; + private const float REFERENCE_EASTING = 575477.5441628407; + private const float REFERENCE_NORTHING = 6224460.129141892; private Wgs84Transformer $transformer; diff --git a/tests/Source/FeedReaderTest.php b/tests/Source/FeedReaderTest.php index d5487f3..8d925b8 100644 --- a/tests/Source/FeedReaderTest.php +++ b/tests/Source/FeedReaderTest.php @@ -16,7 +16,7 @@ class FeedReaderTest extends TestCase * properties, because nothing this class does depends on what the feed * contains — only on the envelope surviving. */ - private const FEATURE_COLLECTION = '{"type":"FeatureCollection","features":[{"example":1},{"example":2}]}'; + private const string FEATURE_COLLECTION = '{"type":"FeatureCollection","features":[{"example":1},{"example":2}]}'; private function reader(?MockHttpClient $client = null): FeedReader { diff --git a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php index 88fc89b..67200f2 100644 --- a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php +++ b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php @@ -12,7 +12,7 @@ class HandicapParkingTest extends TestCase { - private const PARKING_SITE_URN = 'urn:ngsi-ld:ParkingSite:aarhus-on-street'; + private const string PARKING_SITE_URN = 'urn:ngsi-ld:ParkingSite:aarhus-on-street'; /** @var list> */ private array $entities; From b04cb634ceb00c66b7308c952b962ee7e03b48e3 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 1 Sep 2026 14:39:47 +0200 Subject: [PATCH 23/59] Converted all ADRs in readme to draft --- docs/adr/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 1ce7c5e..57f6629 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,14 +4,14 @@ This directory contains Architecture Decision Records (ADRs) for this project. See [adr.github.io](https://adr.github.io/) for background on the format. -| Number | Title | Status | Date | -| ----------------------------------------------- | ------------------------------------------------------- | -------- | ---------- | -| [001](001-architecture-symfony-docker.md) | Architecture — Symfony 8 on the ITK Dev Docker template | Accepted | 2026-08-24 | -| [002](002-publish-to-a-context-broker.md) | Publication mechanism — publish to a context broker | Accepted | 2026-08-24 | -| [003](003-ngsi-ld-representation.md) | NGSI-LD representation — normalized form, batch upsert | Accepted | 2026-08-24 | -| [004](004-coordinate-reference-system.md) | Coordinate reference system — publish WGS84 | Accepted | 2026-08-27 | -| [005](005-smart-data-models-as-vocabulary.md) | Vocabulary — adopt Smart Data Models | Accepted | 2026-08-31 | -| [006](006-onstreetparking-over-parkinggroup.md) | Model selection — OnStreetParking over ParkingGroup | Accepted | 2026-08-31 | +| Number | Title | Status | Date | +| ----------------------------------------------- | ------------------------------------------------------- | ------ | ---------- | +| [001](001-architecture-symfony-docker.md) | Architecture — Symfony 8 on the ITK Dev Docker template | Draft | 2026-08-24 | +| [002](002-publish-to-a-context-broker.md) | Publication mechanism — publish to a context broker | Draft | 2026-08-24 | +| [003](003-ngsi-ld-representation.md) | NGSI-LD representation — normalized form, batch upsert | Draft | 2026-08-24 | +| [004](004-coordinate-reference-system.md) | Coordinate reference system — publish WGS84 | Draft | 2026-08-27 | +| [005](005-smart-data-models-as-vocabulary.md) | Vocabulary — adopt Smart Data Models | Draft | 2026-08-31 | +| [006](006-onstreetparking-over-parkinggroup.md) | Model selection — OnStreetParking over ParkingGroup | Draft | 2026-08-31 | Numbering follows dependency order: each ADR relies only on lower-numbered ones. Dates therefore do not run in the same order as numbers. From 22d50170465069d59f90477df075adad3e8342ea Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Fri, 4 Sep 2026 12:24:39 +0200 Subject: [PATCH 24/59] Added ADR 007: data set metadata in a committed source manifest --- docs/adr/007-source-manifest.md | 122 ++++++++++++++++++++++++++++++++ docs/adr/README.md | 4 +- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 docs/adr/007-source-manifest.md diff --git a/docs/adr/007-source-manifest.md b/docs/adr/007-source-manifest.md new file mode 100644 index 0000000..241eb8f --- /dev/null +++ b/docs/adr/007-source-manifest.md @@ -0,0 +1,122 @@ +# 007: Data set metadata — a committed source manifest + +| Field | Value | +|--------------------|--------------------------------------------------------| +| **Created By** | Jeppe Krogh | +| **Date** | 2026-09-02 | +| **Decision Maker** | ITK Dev team | +| **Stakeholders** | ITK Dev developers, data consumers, future maintainers | +| **Status** | Draft | + +## Context + +Each published data set carries facts the import needs — where the feed is read +from, the coordinate reference system its coordinates are in, and the model it +is published as — and facts only people need: who owns the data, on what terms +it may be republished, how often it changes, and which of its fields are +deliberately not published, with the reason for each. + +The first group must be readable by code. The second is what a public data +portal requires at registration, and what a data owner asks for when +establishing what happened to their data. Both grow with the number of data +sets. + +Recording the two groups separately produces a machine-readable value and a +written description of the same value, which can then disagree. Recording only +the first leaves the rest unwritten, and the questions it answers are then +answered from memory. + +This ADR serves to decide where a data set's own facts are recorded, and which +of them the code reads. + +### Drivers + +- **Functional:** the code reads the facts it needs from the same record a + person reads; a data set is registerable on a public portal without a fresh + survey; an incomplete record fails the import that needs it rather than + producing an incomplete publication. +- **Non-functional:** adding a data set requires no deployment change; the + record is reviewable as a diff; no fact exists in two places. + +### Options Considered + +1. **One environment variable per data set.** Follows the convention that + configuration belongs in the environment, and lets a value differ per + environment. But variable names grow with the catalogue, so each new data + set becomes a deployment change; the environment carries strings only, + leaving metadata beyond an address nowhere to live; and values are invisible + in review, so a wrong one is found by running the import. +2. **Every fact in the class that maps the data set.** Nothing can diverge, + there being one copy, and the language enforces its presence. But metadata + is then readable only by opening code, extracting it for portal registration + requires writing an extractor, and correcting a licence or a contact becomes + a code change reviewed as one. +3. **A committed manifest the code reads.** One record per data set, keyed by + the identifier the import selects it with, holding the facts the code needs + beside those it does not, in a shape a catalogue profile can be generated + from. Validating the record becomes work of our own, and the values are + identical in every environment. +4. **An external catalogue or registry service.** The eventual home of + published metadata, with search and harvesting already built. But it has to + be running for an import to work, it is a second system to operate, and it + must be populated before anything can be published from it — from records + that would have to live somewhere else in the meantime. + +## Decision + +Record each data set in a **committed manifest**, keyed by the identifier the +import selects it with, and read from it every fact the code needs. + +Four rules follow: + +1. **Record only what the code cannot state.** How a feed's fields map onto the + model, and every quirk of its shape, stay in the class that performs the + mapping. Restating them in the manifest recreates the divergence the + manifest exists to prevent. +2. **A fact both the code and a reader need is read from the manifest.** It is + not also written in code, in a comment, or in the README. +3. **An incomplete or malformed record is an error.** Fields an import cannot + run without are required, and their absence raises rather than defaulting. A + fact that is unknown is recorded as unknown, so the gap stays visible. +4. **Name the fields after the catalogue profile the data will be registered + under** — DCAT-AP — so that publication is a translation rather than a + redesign. + +Rationale: + +- What has to be prevented is a value diverging from its description, so the + two belong to the same record. +- Portal registration and answering a data owner need the same fields, and + neither can be derived from mapping code. +- A record is reviewed alongside the class that consumes it, so a reviewer sees + the address, the reference system and the model together with the mapping + that assumes them. +- Where a feed is read from is a fact about the data set, not about the machine + running the import, so the environment is the wrong place for it. + +## Consequences + +### Positive + +- One record per data set, reviewable as a diff and versioned with the code. +- Adding a data set is one class and one record, with no deployment change. +- Registration on a public portal is a translation of records that exist. +- Licence, ownership and what was withheld have a single answer, and an + unanswered question shows as an empty value rather than as nothing at all. +- The same records can generate catalogue entities later without introducing a + second source of truth. + +### Negative / Trade-offs + +- **Values are identical in every environment.** Pointing a data set at a copy + for testing means editing a committed file. That is consistent with reading + feeds where they live, but it removes an escape hatch the environment offered. +- **A wrong reference system or model in the manifest is as damaging as a wrong + one in code, while looking less like code.** A wrong reference system yields + coordinates that are well-formed and in the wrong place. +- **Changing the published model changes entity identifiers.** Editing one + value can therefore orphan everything already published; see ADR 005 and ADR + 006. +- **Fields no code reads have nothing keeping them current.** Until catalogue + entities are generated from them, only review does. +- Validating the record is work the environment did not require. diff --git a/docs/adr/README.md b/docs/adr/README.md index 57f6629..0560124 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,9 +12,11 @@ See [adr.github.io](https://adr.github.io/) for background on the format. | [004](004-coordinate-reference-system.md) | Coordinate reference system — publish WGS84 | Draft | 2026-08-27 | | [005](005-smart-data-models-as-vocabulary.md) | Vocabulary — adopt Smart Data Models | Draft | 2026-08-31 | | [006](006-onstreetparking-over-parkinggroup.md) | Model selection — OnStreetParking over ParkingGroup | Draft | 2026-08-31 | +| [007](007-source-manifest.md) | Data set metadata — a committed source manifest | Draft | 2026-09-02 | Numbering follows dependency order: each ADR relies only on lower-numbered ones. Dates therefore do not run in the same order as numbers. All ADRs state general policy and name no data set. Concrete per-data-set -mappings are recorded in the README and in the source classes themselves. +facts are recorded in `config/sources.yaml`, and the mappings in the source +classes themselves. From 8eb2a3709afea782ac5c0a84896bc4b0a06ed4ee Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Fri, 4 Sep 2026 12:24:46 +0200 Subject: [PATCH 25/59] Added source manifest and catalog reader One record per data set in config/sources.yaml, DCAT-AP field names. SourceCatalog validates lazily and fails loudly on incomplete records. --- config/sources.yaml | 46 ++++++++ src/Source/SourceCatalog.php | 169 +++++++++++++++++++++++++++++ src/Source/SourceDescriptor.php | 43 ++++++++ tests/Source/SourceCatalogTest.php | 144 ++++++++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 config/sources.yaml create mode 100644 src/Source/SourceCatalog.php create mode 100644 src/Source/SourceDescriptor.php create mode 100644 tests/Source/SourceCatalogTest.php diff --git a/config/sources.yaml b/config/sources.yaml new file mode 100644 index 0000000..8081a03 --- /dev/null +++ b/config/sources.yaml @@ -0,0 +1,46 @@ +# The data sets this application publishes, one entry per source key. +# +# The facts the import needs — where the feed is read from, the coordinate +# reference system it publishes, the Smart Data Model it is published as — are +# read from here, so they exist in exactly one place. +# +# The remaining fields record what no code can state: who owns the data, on +# what terms, how often it changes, and which source fields are deliberately +# not published. The rule is to record only what the code cannot tell you, so +# the field mapping itself stays in the source class, which is the only place +# that knows the feed's shape. +# +# Field names follow DCAT-AP, the metadata profile European data portals +# harvest, so registering a data set is a translation of its entry rather than +# a new survey. See docs/adr/007-source-manifest.md. + +sources: + mtm_spatialmaps-handicap-parking: + title: 'Handicapparkering, Aarhus Kommune' + description: >- + Disabled parking bays in Aarhus Municipality, with the number of + reserved bays per location. + publisher: 'Aarhus Kommune' + contact: ppg@aarhus.dk + landing_page: 'https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune' + access_url: 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' + media_type: application/geo+json + crs: 'EPSG:25832' + model: OnStreetParking + update_frequency: continuous + + # The portal states no licence for this data set. DCAT-AP requires + # one, so it has to be settled with the data owner before the + # catalogue can be registered anywhere. + licence: ~ + + # Fields the feed carries that are not published. Recorded here + # because the source class shows what is mapped but cannot show what + # was left out, or why. + omitted_fields: + ident: 'Single-letter code; its meaning is not documented and not confirmed by the data owner.' + oprettet_af: 'Directory username of the municipal employee who created the record — personal data.' + rettet_af: 'Directory username of the municipal employee who last edited the record — personal data.' + oprettet_dato: 'Describes the register record rather than the parking bay.' + rettet_dato: 'Describes the register record rather than the parking bay. A candidate for observedAt, not yet mapped.' + mi_style: 'MapInfo rendering style, empty throughout the export.' diff --git a/src/Source/SourceCatalog.php b/src/Source/SourceCatalog.php new file mode 100644 index 0000000..cdcd9f9 --- /dev/null +++ b/src/Source/SourceCatalog.php @@ -0,0 +1,169 @@ +|null */ + private ?array $descriptors = null; + + public function __construct( + #[Autowire('%kernel.project_dir%/config/sources.yaml')] + private readonly string $manifest, + ) { + } + + /** + * @throws \RuntimeException when the manifest cannot be read, or carries no entry for the key + */ + public function get(string $key): SourceDescriptor + { + $descriptors = $this->all(); + + if (!isset($descriptors[$key])) { + throw new \RuntimeException(\sprintf('No entry for source "%s" in %s. Entries: %s.', $key, $this->manifest, [] === $descriptors ? 'none' : implode(', ', array_keys($descriptors)))); + } + + return $descriptors[$key]; + } + + /** + * @return array keyed by source key + * + * @throws \RuntimeException when the manifest cannot be read + */ + public function all(): array + { + return $this->descriptors ??= $this->load(); + } + + /** + * @return array + */ + private function load(): array + { + if (!is_file($this->manifest)) { + throw new \RuntimeException(\sprintf('Source manifest "%s" does not exist.', $this->manifest)); + } + + try { + $parsed = Yaml::parseFile($this->manifest); + } catch (ParseException $exception) { + throw new \RuntimeException(\sprintf('Source manifest "%s" is not valid YAML: %s', $this->manifest, $exception->getMessage()), previous: $exception); + } + + // Without this the wrong shape yields an empty catalogue, which reads + // as "no data sets are registered" rather than as a broken file. + $sources = \is_array($parsed) ? $parsed['sources'] ?? null : null; + if (!\is_array($sources)) { + throw new \RuntimeException(\sprintf('Source manifest "%s" must contain a "sources" mapping at the top level.', $this->manifest)); + } + + $descriptors = []; + foreach ($sources as $key => $entry) { + $key = (string) $key; + + if (!\is_array($entry)) { + throw new \RuntimeException(\sprintf('Entry "%s" in %s must be a mapping, got %s.', $key, $this->manifest, get_debug_type($entry))); + } + + $descriptors[$key] = $this->descriptor($key, $entry); + } + + return $descriptors; + } + + /** + * @param array $entry + */ + private function descriptor(string $key, array $entry): SourceDescriptor + { + return new SourceDescriptor( + key: $key, + title: $this->required($key, $entry, 'title'), + accessUrl: $this->required($key, $entry, 'access_url'), + crs: $this->required($key, $entry, 'crs'), + model: $this->required($key, $entry, 'model'), + description: $this->optional($key, $entry, 'description'), + publisher: $this->optional($key, $entry, 'publisher'), + contact: $this->optional($key, $entry, 'contact'), + landingPage: $this->optional($key, $entry, 'landing_page'), + mediaType: $this->optional($key, $entry, 'media_type'), + updateFrequency: $this->optional($key, $entry, 'update_frequency'), + licence: $this->optional($key, $entry, 'licence'), + omittedFields: $this->omittedFields($key, $entry), + ); + } + + /** + * @param array $entry + */ + private function required(string $key, array $entry, string $field): string + { + return $this->optional($key, $entry, $field) + ?? throw new \RuntimeException(\sprintf('Entry "%s" in %s is missing the required field "%s"; an import cannot run without it.', $key, $this->manifest, $field)); + } + + /** + * An empty value means "not filled in", the same as an absent key, so both + * become null rather than an empty string. + * + * @param array $entry + */ + private function optional(string $key, array $entry, string $field): ?string + { + $value = $entry[$field] ?? null; + + if (null === $value || '' === $value) { + return null; + } + + if (!is_scalar($value)) { + throw new \RuntimeException(\sprintf('Field "%s" of entry "%s" in %s must be a single value, got %s.', $field, $key, $this->manifest, get_debug_type($value))); + } + + return trim((string) $value); + } + + /** + * @param array $entry + * + * @return array + */ + private function omittedFields(string $key, array $entry): array + { + $omitted = $entry['omitted_fields'] ?? []; + + if (!\is_array($omitted)) { + throw new \RuntimeException(\sprintf('Field "omitted_fields" of entry "%s" in %s must map each field to the reason it is not published.', $key, $this->manifest)); + } + + $reasons = []; + foreach ($omitted as $field => $reason) { + // The reason is the half of the record that cannot be recovered + // from the code, so a bare list of names is not accepted. + if (!\is_string($reason) || '' === trim($reason)) { + throw new \RuntimeException(\sprintf('Omitted field "%s" of entry "%s" in %s needs a reason.', $field, $key, $this->manifest)); + } + + $reasons[(string) $field] = trim($reason); + } + + return $reasons; + } +} diff --git a/src/Source/SourceDescriptor.php b/src/Source/SourceDescriptor.php new file mode 100644 index 0000000..83b2b14 --- /dev/null +++ b/src/Source/SourceDescriptor.php @@ -0,0 +1,43 @@ + $omittedFields source field name => why it is not published + */ + public function __construct( + public string $key, + public string $title, + public string $accessUrl, + public string $crs, + public string $model, + public ?string $description = null, + public ?string $publisher = null, + public ?string $contact = null, + public ?string $landingPage = null, + public ?string $mediaType = null, + public ?string $updateFrequency = null, + public ?string $licence = null, + public array $omittedFields = [], + ) { + } +} diff --git a/tests/Source/SourceCatalogTest.php b/tests/Source/SourceCatalogTest.php new file mode 100644 index 0000000..e5f1dbd --- /dev/null +++ b/tests/Source/SourceCatalogTest.php @@ -0,0 +1,144 @@ + */ + private array $written = []; + + protected function tearDown(): void + { + foreach ($this->written as $path) { + if (is_file($path)) { + unlink($path); + } + } + + $this->written = []; + } + + public function testTheShippedManifestIsUsable(): void + { + $catalog = new SourceCatalog(\dirname(__DIR__, 2).'/config/sources.yaml'); + + $this->assertNotSame([], $catalog->all(), 'The manifest registers no data sets.'); + } + + /** + * A wrong URL scheme or a CRS the transformer does not know only surfaces + * mid-import otherwise, after the feed has been fetched. + */ + public function testEveryShippedEntryCanBeImportedFrom(): void + { + $catalog = new SourceCatalog(\dirname(__DIR__, 2).'/config/sources.yaml'); + + foreach ($catalog->all() as $key => $descriptor) { + $this->assertSame($key, $descriptor->key); + $this->assertMatchesRegularExpression('#^https?://#', $descriptor->accessUrl, $key); + $this->assertMatchesRegularExpression('/^EPSG:\d+$/', $descriptor->crs, $key); + $this->assertNotSame('', $descriptor->model, $key); + } + } + + public function testItNamesTheKnownEntriesWhenAskedForAnUnknownOne(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + YAML)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Entries: a-source.'); + + $catalog->get('no-such-source'); + } + + public function testItRejectsAManifestWithoutASourcesMapping(): void + { + $catalog = new SourceCatalog($this->manifest("data_sets:\n a-source: {}\n")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must contain a "sources" mapping'); + + $catalog->all(); + } + + public function testItRejectsAnEntryMissingAFieldTheImportNeeds(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + model: Example + YAML)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('missing the required field "crs"'); + + $catalog->all(); + } + + public function testItRejectsAnOmittedFieldWithoutAReason(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + omitted_fields: + some_field: ~ + YAML)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('needs a reason'); + + $catalog->all(); + } + + public function testItReportsAManifestThatIsNotThere(): void + { + $catalog = new SourceCatalog('/no/such/sources.yaml'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('does not exist'); + + $catalog->all(); + } + + public function testItReportsUnparsableYaml(): void + { + $catalog = new SourceCatalog($this->manifest("sources:\n - [unbalanced\n")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('is not valid YAML'); + + $catalog->all(); + } + + private function manifest(string $yaml): string + { + $path = tempnam(sys_get_temp_dir(), 'sources-'); + + if (false === $path) { + $this->fail('Could not create a temporary manifest.'); + } + + file_put_contents($path, $yaml); + $this->written[] = $path; + + return $path; + } +} From 8e3683218d6d6cd5feb048021059a957bae421a5 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Fri, 4 Sep 2026 12:24:55 +0200 Subject: [PATCH 26/59] Moved handicap parking feed config from .env to the source manifest Removes ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE; the feed URL, CRS and model now come from the manifest entry, and the entity type and URN prefix both derive from its model field. Rewrote the test, which called a constructor signature that no longer existed and read a fixture file not in the repo. --- .env | 6 +- src/Source/MtmSpatialMaps/HandicapParking.php | 43 ++--- .../MtmSpatialMaps/HandicapParkingTest.php | 169 ++++++++++++------ 3 files changed, 141 insertions(+), 77 deletions(-) diff --git a/.env b/.env index 5e53eba..a9d7b27 100644 --- a/.env +++ b/.env @@ -43,7 +43,7 @@ ENTER_NGSI_CONTEXT_URLS='https://raw.githubusercontent.com/smart-data-models/dat # being read — Parking while that is the only model published. ENTER_NGSI_DOMAIN_CONTEXT=https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld -# Where the disabled-parking export is read from: the live SpatialMap export. -# Must be an http(s) URL — feeds are read where they live, never from a copy. -ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE='https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' +# Where each feed is read from is not configured here. It belongs to the data +# set rather than to the environment, and lives in config/sources.yaml — see +# docs/adr/007-source-manifest.md. ###< app ### diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 296340b..72e0653 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -7,47 +7,50 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; use App\Source\FeedReader; +use App\Source\SourceCatalog; +use App\Source\SourceDescriptor; use App\Source\SourceInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Disabled parking bays in Aarhus Municipality, exported from SpatialMap. - * https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap. * - * Published at site level as OnStreetParking rather than as a ParkingGroup - * subdivision: the feed describes locations with a count of reserved bays and - * nothing above them, and ParkingGroup requires a parent site this source does - * not contain. See ADR 006, and ADR 005 rule 2 for the principle behind it. + * Where the feed is read from, the CRS it publishes and the model it is + * published as come from this key's manifest entry; this class owns only the + * field mapping. * + * Published at site level rather than as a ParkingGroup subdivision: the feed + * describes locations with a count of reserved bays and nothing above them, + * and ParkingGroup requires a parent site this source does not contain. See + * ADR 006, and ADR 005 rule 2 for the principle behind it. + * + * @see config/sources.yaml * @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking */ final readonly class HandicapParking implements SourceInterface { - /** - * The CRS(coordinate reference system) this feed publishes - SRID (Spatial reference identifier). - */ - private const string SOURCE_SRID = 'EPSG:25832'; + private const string KEY = 'mtm_spatialmaps-handicap-parking'; public function __construct( private FeedReader $reader, private Wgs84Transformer $transformer, - #[Autowire(env: 'ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE')] - private string $location, + private SourceCatalog $catalog, ) { } public function key(): string { - return 'mtm_spatialmaps-handicap-parking'; + return self::KEY; } public function entities(): iterable { + $source = $this->catalog->get(self::KEY); + // The export is a GeoJSON FeatureCollection, so the records live under // `features`. Iterating the document itself would walk its two // top-level keys instead. - foreach ($this->reader->read($this->location)['features'] ?? [] as $feature) { - if (\is_array($feature) && null !== $entity = $this->toEntity($feature)) { + foreach ($this->reader->read($source->accessUrl)['features'] ?? [] as $feature) { + if (\is_array($feature) && null !== $entity = $this->toEntity($feature, $source)) { yield $entity; } } @@ -56,7 +59,7 @@ public function entities(): iterable /** * @param array $feature GeoJSON Feature */ - private function toEntity(array $feature): ?NgsiEntity + private function toEntity(array $feature, SourceDescriptor $source): ?NgsiEntity { // A Feature keeps its attributes under `properties` and its geometry // beside them, so neither is at the feature's top level. @@ -76,8 +79,8 @@ private function toEntity(array $feature): ?NgsiEntity } $entity = new NgsiEntity( - \sprintf('urn:ngsi-ld:OnStreetParking:aarhus-handicap-%s', $key), - 'OnStreetParking' + \sprintf('urn:ngsi-ld:%s:aarhus-handicap-%s', $source->model, $key), + $source->model ); // `forDisabled` rather than ParkingGroup's `onlyDisabled`: the two @@ -88,8 +91,8 @@ private function toEntity(array $feature): ?NgsiEntity ->property('description', trim((string) ($row['bemrk'] ?? ''))) ->property('category', ['forDisabled']) ->property('totalSpotNumber', (int) ($row['invalidepladser'] ?? 0)) - ->property('source', $this->location) - ->geoProperty('location', $this->transformer->geometry(self::SOURCE_SRID, $geometry)); + ->property('source', $source->accessUrl) + ->geoProperty('location', $this->transformer->geometry($source->crs, $geometry)); } /** diff --git a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php index 67200f2..e0065a7 100644 --- a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php +++ b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php @@ -6,27 +6,41 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; +use App\Source\FeedReader; use App\Source\MtmSpatialMaps\HandicapParking; +use App\Source\SourceCatalog; +use App\Source\SourceDescriptor; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +/** + * Runs against the manifest the project ships, not a fixture of one, so the + * entry this source depends on is covered too. Only the feed is mocked. + */ class HandicapParkingTest extends TestCase { - private const string PARKING_SITE_URN = 'urn:ngsi-ld:ParkingSite:aarhus-on-street'; + private const string KEY = 'mtm_spatialmaps-handicap-parking'; + + private SourceDescriptor $source; + + private string $requestedUrl; /** @var list> */ private array $entities; protected function setUp(): void { - $source = new HandicapParking( - new Wgs84Transformer(), - // Never used: the fixture is read from disk, not over HTTP. - new MockHttpClient(), - 'data/handicapparkering.json', - self::PARKING_SITE_URN, - \dirname(__DIR__, 2), - ); + $catalog = new SourceCatalog(\dirname(__DIR__, 3).'/config/sources.yaml'); + $this->source = $catalog->get(self::KEY); + + $client = new MockHttpClient(function (string $method, string $url): MockResponse { + $this->requestedUrl = $url; + + return new MockResponse(json_encode($this->feed(), \JSON_THROW_ON_ERROR)); + }); + + $source = new HandicapParking(new FeedReader($client), new Wgs84Transformer(), $catalog); $this->entities = array_map( static fn (NgsiEntity $entity): array => $entity->toArray(['https://example.com/context.jsonld']), @@ -34,96 +48,143 @@ protected function setUp(): void ); } - public function testItReadsEveryRecordInTheFixture(): void + public function testItReadsTheFeedTheManifestPointsAt(): void + { + $this->assertSame($this->source->accessUrl, $this->requestedUrl); + } + + public function testItSkipsRecordsWithoutAnIdentifierOrGeometry(): void { - $this->assertCount(10, $this->entities); + // Four features, of which one has no mi_prinx and one no geometry. + $this->assertCount(2, $this->entities); } - public function testItBuildsAParkingGroupWithAStableId(): void + public function testItTakesIdentifierAndTypeFromTheManifestModel(): void { $first = $this->entities[0]; // The id is derived from mi_prinx so that re-importing upserts the - // same entity instead of creating a duplicate. - $this->assertSame('urn:ngsi-ld:ParkingGroup:aarhus-handicap-261', $first['id']); - $this->assertSame('ParkingGroup', $first['type']); + // same bay instead of creating a duplicate. + $this->assertSame( + \sprintf('urn:ngsi-ld:%s:aarhus-handicap-172', $this->source->model), + $first['id'] + ); + $this->assertSame($this->source->model, $first['type']); } public function testItJoinsStreetAndHouseNumberIntoName(): void { - $this->assertSame('P.P. Ørums Gade 2', $this->entities[0]['name']['value']); + $this->assertSame('Domkirkeplads/Bispegade 1', $this->entities[0]['name']['value']); } public function testItOmitsTheHouseNumberWhenBlank(): void { - $brammersgade = $this->entityById('urn:ngsi-ld:ParkingGroup:aarhus-handicap-378'); - // husnnr is "" for this row, so the name must not end in a space. - $this->assertSame('Brammersgade', $brammersgade['name']['value']); + $this->assertSame('Brammersgade', $this->entities[1]['name']['value']); } - public function testItMarksEveryGroupAsOnStreetDisabledParking(): void + public function testItMarksEveryEntityAsDisabledParking(): void { foreach ($this->entities as $entity) { - $this->assertSame(['onStreet', 'onlyDisabled'], $entity['category']['value']); + $this->assertSame(['forDisabled'], $entity['category']['value']); } } public function testItCarriesTheBayCountAsTotalSpotNumber(): void { - $this->assertSame(2, $this->entities[0]['totalSpotNumber']['value']); + $this->assertSame(6, $this->entities[0]['totalSpotNumber']['value']); $this->assertSame(1, $this->entities[1]['totalSpotNumber']['value']); } - /** - * The feed stores dates as Excel serial numbers with a decimal comma - * ("43543,6190690972"), which has to become a real timestamp. - */ - public function testItConvertsExcelSerialDatesToIso8601(): void + public function testItDropsEmptyDescriptions(): void { - $observedAt = $this->entities[0]['totalSpotNumber']['observedAt']; - - $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $observedAt); - $this->assertStringStartsWith('2019-03-', $observedAt); + // bemrk is null for the first row and filled in for the second. + $this->assertArrayNotHasKey('description', $this->entities[0]); + $this->assertSame('Ved indgangen', $this->entities[1]['description']['value']); } - public function testItEmitsLocationAsAGeoProperty(): void + public function testItReprojectsLocationIntoWgs84(): void { $location = $this->entities[0]['location']; $this->assertSame('GeoProperty', $location['type']); $this->assertSame('Point', $location['value']['type']); - } - public function testItDropsEmptyDescriptions(): void - { - // bemrk is "" for the first row and "Navitas" for mi_prinx 215. - $this->assertArrayNotHasKey('description', $this->entities[0]); - $this->assertSame( - 'Navitas', - $this->entityById('urn:ngsi-ld:ParkingGroup:aarhus-handicap-215')['description']['value'] - ); + // The feed publishes metres in the manifest's CRS, so degrees within + // Denmark are the evidence that the reprojection ran. + [$longitude, $latitude] = $location['value']['coordinates']; + $this->assertGreaterThan(8.0, $longitude); + $this->assertLessThan(13.0, $longitude); + $this->assertGreaterThan(54.5, $latitude); + $this->assertLessThan(57.8, $latitude); } - public function testItRelatesEveryGroupToTheSyntheticParkingSite(): void + public function testItRecordsTheManifestUrlAsTheEntitySource(): void { - foreach ($this->entities as $entity) { - $this->assertSame('Relationship', $entity['refParkingSite']['type']); - $this->assertSame(self::PARKING_SITE_URN, $entity['refParkingSite']['object']); - } + $this->assertSame($this->source->accessUrl, $this->entities[0]['source']['value']); } /** + * The first feature is a record from the live export, kept verbatim. The + * rest are constructed to exercise a blank house number and the two guards + * that discard a record. + * * @return array */ - private function entityById(string $id): array + private function feed(): array { - foreach ($this->entities as $entity) { - if ($id === $entity['id']) { - return $entity; - } - } - - $this->fail(\sprintf('No entity with id "%s".', $id)); + return [ + 'type' => 'FeatureCollection', + 'crs' => ['type' => 'name', 'properties' => ['name' => 'EPSG:25832']], + 'features' => [ + [ + 'type' => 'Feature', + 'geometry' => ['type' => 'Point', 'coordinates' => [575153.9524951308, 6224260.609753487]], + 'properties' => [ + 'vejnavn' => 'Domkirkeplads/Bispegade', + 'husnnr' => '1', + 'invalidepladser' => 6, + 'bemrk' => null, + 'ident' => 'P', + 'oprettet_af' => 'ADM\\aztnbnd', + 'oprettet_dato' => '2019-03-19 14:51:23.91', + 'rettet_af' => 'ADM\\aztnbnd', + 'rettet_dato' => '2019-03-19 14:51:23.91', + 'mi_style' => null, + 'mi_prinx' => 172, + ], + ], + [ + 'type' => 'Feature', + 'geometry' => ['type' => 'Point', 'coordinates' => [574000.0, 6223000.0]], + 'properties' => [ + 'vejnavn' => 'Brammersgade', + 'husnnr' => '', + 'invalidepladser' => 1, + 'bemrk' => 'Ved indgangen', + 'mi_prinx' => 378, + ], + ], + [ + 'type' => 'Feature', + 'geometry' => ['type' => 'Point', 'coordinates' => [574100.0, 6223100.0]], + 'properties' => [ + 'vejnavn' => 'Uden nøgle', + 'husnnr' => '3', + 'invalidepladser' => 2, + ], + ], + [ + 'type' => 'Feature', + 'geometry' => null, + 'properties' => [ + 'vejnavn' => 'Uden geometri', + 'husnnr' => '5', + 'invalidepladser' => 2, + 'mi_prinx' => 999, + ], + ], + ], + ]; } } From 0bd7178b40eac6b54c88f2bcb9198f1a1cb732fa Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Fri, 4 Sep 2026 12:25:03 +0200 Subject: [PATCH 27/59] Updated README and changelog for the source manifest --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 43 +++++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e140da..5758a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,5 +21,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `task broker:entities` for reading entities back out of the broker. - Architecture Decision Records under `docs/adr`. - Added test suite +- `config/sources.yaml`: one record per data set, holding the feed URL, its CRS + and the model it is published as alongside the metadata no code can state — + owner, contact, licence, update frequency, and the source fields deliberately + left unpublished with the reason for each. Field names follow DCAT-AP. +- `SourceCatalog` and `SourceDescriptor`: read and validate the manifest, + failing loudly on a missing or malformed record rather than defaulting. + +### Changed + +- Sources read their feed URL, CRS and model from `config/sources.yaml` instead + of holding them in an environment variable and a class constant. + +### Removed + +- `ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE`, and with it the pattern of + one environment variable per data set. Where a feed is read from is a fact + about the data set, not about the environment. [Unreleased]: https://github.com/itk-dev/enter diff --git a/README.md b/README.md index b6a8d5f..511ead3 100644 --- a/README.md +++ b/README.md @@ -26,15 +26,17 @@ source feed (JSON) → context broker ``` -| Class | Responsibility | -| ------------------------------------------- | ------------------------------------------------ | -| `App\Source\SourceInterface` | Contract for one input data set | -| `App\Source\FeedReader` | Feed URL → decoded JSON | -| `App\Source\MtmSpatialMaps\HandicapParking` | Disabled parking bays → `OnStreetParking` | -| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | -| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | -| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | -| `App\Command\ImportCommand` | `app:import` | +| Class | Responsibility | +| ------------------------------------------- | ------------------------------------------------- | +| `App\Source\SourceInterface` | Contract for one input data set | +| `App\Source\SourceCatalog` | Reads the source manifest | +| `App\Source\SourceDescriptor` | One manifest entry: what a data set is and where | +| `App\Source\FeedReader` | Feed URL → decoded JSON | +| `App\Source\MtmSpatialMaps\HandicapParking` | Disabled parking bays → `OnStreetParking` | +| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | +| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | +| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | +| `App\Command\ImportCommand` | `app:import` | ``` shell task import # list the available sources @@ -43,13 +45,30 @@ task import -- mtm_spatialmaps-handicap-parking --dry-run --limit 5 # print t task broker:entities -- OnStreetParking 10 # read back what landed ``` -Adding a data set means adding one `SourceInterface` implementation. It is -discovered through `#[AutoconfigureTag('app.source')]` and shows up as an -`app:import` argument with no further wiring. +### Source manifest + +Every data set is recorded in [config/sources.yaml](config/sources.yaml), keyed +by the identifier `app:import` takes as its argument. The feed URL, the +coordinate reference system it publishes and the Smart Data Model it is +published as are read from there, so each exists in one place only. The rest of +an entry is what no code can state: owner, contact, licence, update frequency, +and the source fields deliberately left unpublished with the reason for each. +The field mapping stays in the source class, which is the only place that knows +the feed's shape. + +Field names follow [DCAT-AP], the metadata profile European data portals +harvest, so registering a data set is a translation of its entry rather than a +new survey. See [ADR 007](docs/adr/007-source-manifest.md). + +Adding a data set means adding one `SourceInterface` implementation and one +manifest entry. The class is discovered through +`#[AutoconfigureTag('app.source')]` and shows up as an `app:import` argument +with no further wiring. Design decisions are recorded in [docs/adr](docs/adr/README.md). [NGSI-LD]: https://www.etsi.org/committee/cim +[DCAT-AP]: https://semiceu.github.io/DCAT-AP/ ## Broker From 1d1e303562728098ad0dbf0470ba66a3368040cc Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:18:13 +0200 Subject: [PATCH 28/59] Cleaned up sources.yaml --- config/sources.yaml | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/config/sources.yaml b/config/sources.yaml index 8081a03..b011401 100644 --- a/config/sources.yaml +++ b/config/sources.yaml @@ -1,15 +1,5 @@ # The data sets this application publishes, one entry per source key. # -# The facts the import needs — where the feed is read from, the coordinate -# reference system it publishes, the Smart Data Model it is published as — are -# read from here, so they exist in exactly one place. -# -# The remaining fields record what no code can state: who owns the data, on -# what terms, how often it changes, and which source fields are deliberately -# not published. The rule is to record only what the code cannot tell you, so -# the field mapping itself stays in the source class, which is the only place -# that knows the feed's shape. -# # Field names follow DCAT-AP, the metadata profile European data portals # harvest, so registering a data set is a translation of its entry rather than # a new survey. See docs/adr/007-source-manifest.md. @@ -39,8 +29,8 @@ sources: # was left out, or why. omitted_fields: ident: 'Single-letter code; its meaning is not documented and not confirmed by the data owner.' - oprettet_af: 'Directory username of the municipal employee who created the record — personal data.' - rettet_af: 'Directory username of the municipal employee who last edited the record — personal data.' - oprettet_dato: 'Describes the register record rather than the parking bay.' - rettet_dato: 'Describes the register record rather than the parking bay. A candidate for observedAt, not yet mapped.' + oprettet_af: 'Directory username of the municipal employee who created the record.' + rettet_af: 'Directory username of the municipal employee who last edited the record.' + oprettet_dato: 'Describes the register record.' + rettet_dato: 'Describes the register record.' mi_style: 'MapInfo rendering style, empty throughout the export.' From fd623fc8511addaad9348a375f68c150643aacfa Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:25:03 +0200 Subject: [PATCH 29/59] Cleaned up ADR 007 --- docs/adr/007-source-manifest.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/adr/007-source-manifest.md b/docs/adr/007-source-manifest.md index 241eb8f..d644c8f 100644 --- a/docs/adr/007-source-manifest.md +++ b/docs/adr/007-source-manifest.md @@ -26,8 +26,7 @@ written description of the same value, which can then disagree. Recording only the first leaves the rest unwritten, and the questions it answers are then answered from memory. -This ADR serves to decide where a data set's own facts are recorded, and which -of them the code reads. +This ADR serves to decide where a data set's own facts are recorded. ### Drivers @@ -114,9 +113,6 @@ Rationale: - **A wrong reference system or model in the manifest is as damaging as a wrong one in code, while looking less like code.** A wrong reference system yields coordinates that are well-formed and in the wrong place. -- **Changing the published model changes entity identifiers.** Editing one - value can therefore orphan everything already published; see ADR 005 and ADR - 006. - **Fields no code reads have nothing keeping them current.** Until catalogue entities are generated from them, only review does. - Validating the record is work the environment did not require. From bfcac8753c818bb2bcc3cfcbc1fc47021440fde8 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:27:37 +0200 Subject: [PATCH 30/59] Shortened comment --- src/Source/MtmSpatialMaps/HandicapParking.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 72e0653..66a0c55 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -12,16 +12,7 @@ use App\Source\SourceInterface; /** - * Disabled parking bays in Aarhus Municipality, exported from SpatialMap. - * - * Where the feed is read from, the CRS it publishes and the model it is - * published as come from this key's manifest entry; this class owns only the - * field mapping. - * - * Published at site level rather than as a ParkingGroup subdivision: the feed - * describes locations with a count of reserved bays and nothing above them, - * and ParkingGroup requires a parent site this source does not contain. See - * ADR 006, and ADR 005 rule 2 for the principle behind it. + * Disabled parking bays in Aarhus Municipality * * @see config/sources.yaml * @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking From f51f6111809ba0b03205370ab5ef0b50edfbd4b4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:41:28 +0200 Subject: [PATCH 31/59] Updated changelog --- CHANGELOG.md | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5758a47..b089b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,36 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- `app:import` command with `--dry-run` and `--limit`, exposed as `task import`. -- `SourceInterface`: extension point for further data sets, discovered through - `#[AutoconfigureTag('app.source')]`. -- `Wgs84Transformer`: reprojects coordinates from any registered CRS to WGS84, - for a single position or a whole GeoJSON geometry of any type. -- `FeedReader`: reads a feed from a filesystem path or an http(s) URL and decodes - it, without interpreting its shape. -- `NgsiEntity`: builds normalized NGSI-LD entities. -- `NgsiLdBroker`: idempotent batch upsert to an NGSI-LD context broker. -- `task broker:entities` for reading entities back out of the broker. -- Architecture Decision Records under `docs/adr`. -- Added test suite -- `config/sources.yaml`: one record per data set, holding the feed URL, its CRS - and the model it is published as alongside the metadata no code can state — - owner, contact, licence, update frequency, and the source fields deliberately - left unpublished with the reason for each. Field names follow DCAT-AP. -- `SourceCatalog` and `SourceDescriptor`: read and validate the manifest, - failing loudly on a missing or malformed record rather than defaulting. - -### Changed - -- Sources read their feed URL, CRS and model from `config/sources.yaml` instead - of holding them in an environment variable and a class constant. - -### Removed - -- `ENTER_MTM_SPATIALMAPS_HANDICAP_PARKING_SOURCE`, and with it the pattern of - one environment variable per data set. Where a feed is read from is a fact - about the data set, not about the environment. +* [#3](https://github.com/itk-dev/enter/pull/3) + * Import command that reads a geospatial feed, reprojects it to WGS84 and upserts it to an NGSI-LD broker. + * A committed record per data set — feed, CRS, model, DCAT-AP metadata. + * An extension point for adding data sets, a test suite, and architecture decision records. [Unreleased]: https://github.com/itk-dev/enter From e556c01f5e858053a354e5105e3a7419de28fe9c Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:45:08 +0200 Subject: [PATCH 32/59] Updated README --- README.md | 166 +----------------------------------------------------- 1 file changed, 2 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index 511ead3..6762905 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Run `task` to see what cool task are available. Running `ddev` can help with oth ## Adapter -Takes an Aarhus open-data set, converts it to [NGSI-LD], and upserts it into the +Takes an open-data set, converts it to [NGSI-LD], and upserts it into the context broker. ``` text @@ -26,18 +26,6 @@ source feed (JSON) → context broker ``` -| Class | Responsibility | -| ------------------------------------------- | ------------------------------------------------- | -| `App\Source\SourceInterface` | Contract for one input data set | -| `App\Source\SourceCatalog` | Reads the source manifest | -| `App\Source\SourceDescriptor` | One manifest entry: what a data set is and where | -| `App\Source\FeedReader` | Feed URL → decoded JSON | -| `App\Source\MtmSpatialMaps\HandicapParking` | Disabled parking bays → `OnStreetParking` | -| `App\Geo\Wgs84Transformer` | Any registered CRS → WGS84, any GeoJSON geometry | -| `App\Ngsi\NgsiEntity` | Builds normalized NGSI-LD entities | -| `App\Broker\NgsiLdBroker` | Batch upsert to the broker | -| `App\Command\ImportCommand` | `app:import` | - ``` shell task import # list the available sources task import -- mtm_spatialmaps-handicap-parking # import one @@ -48,17 +36,7 @@ task broker:entities -- OnStreetParking 10 # read back what land ### Source manifest Every data set is recorded in [config/sources.yaml](config/sources.yaml), keyed -by the identifier `app:import` takes as its argument. The feed URL, the -coordinate reference system it publishes and the Smart Data Model it is -published as are read from there, so each exists in one place only. The rest of -an entry is what no code can state: owner, contact, licence, update frequency, -and the source fields deliberately left unpublished with the reason for each. -The field mapping stays in the source class, which is the only place that knows -the feed's shape. - -Field names follow [DCAT-AP], the metadata profile European data portals -harvest, so registering a data set is a translation of its entry rather than a -new survey. See [ADR 007](docs/adr/007-source-manifest.md). +by the identifier `app:import` takes as its argument. See [ADR 007](docs/adr/007-source-manifest.md). Adding a data set means adding one `SourceInterface` implementation and one manifest entry. The class is discovered through @@ -77,143 +55,3 @@ A [Scorpio Broker](https://scorpio.readthedocs.io/) is part of the development s ``` shell ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/types | jq" ``` - -Load some example data: - -``` shell name=import-toilet -ddev console app:broker:entity:delete toilet -ddev console app:broker:import:geojson toilet 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter' -ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=toilet" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location"])))' -``` - -``` shell name=import-handicapparkering -ddev console app:broker:entity:delete handicapparkering -ddev console app:broker:import:geojson handicapparkering 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap' -ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=handicapparkering" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location"])))' -``` - -``` shell name=import-hundeskov -ddev console app:broker:entity:delete hundeskov -ddev console app:broker:import:geojson hundeskov 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=hundeskove_friluftsliv_aarhus' -ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --get --data-urlencode type=hundeskov" | jq '.[]|with_entries(select([.key] | inside(["id", "type", "location", "geometry"])))' -``` - -### Broker API request examples - - - -``` shell name=scorpio-entity-create substitutions="{«entity-type»: Room, «entity-id»: 'house2:smartrooms:room1'}" -ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities --header 'content-type: application/json' --data @-" <<'JSON' -{ - "type": "«entity-type»", - "id": "«entity-id»" -} -JSON - - -# EPSG:4326?! -ddev exec "curl --silent http://scorpio.local:9090/ngsi-ld/v1/entities/«entity-id»/attrs --header 'content-type: application/json' --data @-" <<'JSON' -{ - "location": { - "type": "geo:json", - "value": { - "type": "Point", - "coordinates": [ - 10.15711687080293, 56.126271111641266 - ] - } - } -} -JSON - - -# Get the entities -ddev exec "curl --silent --header 'accept: application/ld+json' http://scorpio.local:9090/ngsi-ld/v1/entities?type=«entity-type» | jq" - -# Get the entities as GeoJSON -ddev exec "curl --silent --header 'accept: application/geo+json' http://scorpio.local:9090/ngsi-ld/v1/entities?type=«entity-type» | jq" -``` - -> [!CAUTION] -> Excuse me what?! -> -> ``` shell -> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance%3D%3D2000&geometry=Point&coordinates=%5B8,40%5D'" -> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance==2000&geometry=Point&coordinates=%5B8,40%5D'" -> ddev exec "curl --silent --header 'accept: application/geo+json' 'http://scorpio.local:9090/ngsi-ld/v1/entities?georel=near;maxDistance==2000&geometry=Point&coordinates=[10,56]'" -> > ``` - -## GeoJSON - -* "[Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1)": `(longitude, latitude)` - * "[Coordinate Reference System](https://datatracker.ietf.org/doc/html/rfc7946#section-4)" - * But … - - : - - ```json - { - "type": "FeatureCollection", - "crs": { - "type": "name", - "properties": { - "name": "EPSG:25832" - } - }, - "bbox": [563262.0903961, 6209995.05346621, 579587.800694027, 6231954.36230685], - "features": [ - { - "type": "Feature", - "geometry": { - "type": "MultiPoint", - "coordinates": [ - [576933.018139537, 6218035.22691216] - ] - }, - "properties": { - … - ``` - -* - ---- - -* -* -* -* - -Must `location` be a `Point` in ngsi-ld? - -``` shell -ddev exec --service scorpio-db "psql ngb ngb" -``` - -``` sql -2026-08-30 10:26:19.789 UTC [41] LOG: execute 0000000: WITH D0 AS (SELECT ID, ENTITY, TRUE as PARENT FROM ENTITY WHERE ST_DWithin( location::geography, ST_SetSRID(ST_GeomFromGeoJSON('{"type": "Point", "coordinates": [10.236808047161853,56.101226991176155] }'), 4326)::geography, 2000.0) ORDER BY createdAt limit $1 offset $2) SELECT ID, ENTITY, PARENT FROM D0 -``` - - - -``` shell -ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT id , e_types, location FROM entity;" -``` - -``` shell name=hmm -ddev console app:import:geojson toilet 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter' \ - && ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT id , e_types, ST_AsText(location) AS location FROM entity WHERE id = 'toilet:0000';" \ - && ddev exec --service scorpio-db "psql ngb ngb" <<< "SELECT temporalentity_id, ST_AsText(location) AS location, ST_asText(geovalue) AS geovalue, createdat FROM temporalentityattrinstance WHERE temporalentity_id = 'toilet:0000' ORDER BY createdat DESC LIMIT 10;" -``` - - - -* [Public toilets in Aarhus Municipality](https://www.opendata.dk/city-of-aarhus/toiletter-i-aarhus-kommune) - * [Offentlige - toiletter](http://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=andre_toiletter): - -* [Parking in Aarhus Municipality – Zones, Permits and - Spaces](https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune) - * [Handicapparkering](https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap): - - -* From 8e0627b21acdae7542b27c59d54307b8faa59e2e Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:46:16 +0200 Subject: [PATCH 33/59] Removed comment from Taskfile --- Taskfile.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 6ac1694..b5cfede 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,11 +40,6 @@ tasks: desc: 'Import a source into the broker, e.g. task import -- mtm_spatialmaps-handicap-parking' cmd: ddev console app:import {{.CLI_ARGS}} - # The broker itself needs no start task: Scorpio is a DDEV custom service - # (.ddev/docker-compose.scorpio.yaml), so it comes up with the rest of the - # site. Read requests need the domain context in a Link header, which the - # script supplies; the env values come from the dotenv block above, and are - # passed in explicitly because the container shell does not read .env. broker:entities: desc: 'List broker entities of a type, e.g. task broker:entities -- OnStreetParking 10' cmd: >- From c29945e4e383b5466c0d153d961608a0d7a0c3d9 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 10:48:43 +0200 Subject: [PATCH 34/59] Fixed coding standards --- README.md | 1 - src/Source/MtmSpatialMaps/HandicapParking.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6762905..3957294 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ with no further wiring. Design decisions are recorded in [docs/adr](docs/adr/README.md). [NGSI-LD]: https://www.etsi.org/committee/cim -[DCAT-AP]: https://semiceu.github.io/DCAT-AP/ ## Broker diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 66a0c55..bf7dead 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -12,7 +12,7 @@ use App\Source\SourceInterface; /** - * Disabled parking bays in Aarhus Municipality + * Disabled parking bays in Aarhus Municipality. * * @see config/sources.yaml * @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking From 01cfeb5b227914bf6837440bd8ffae96299376b3 Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:55:31 +0200 Subject: [PATCH 35/59] Update src/Geo/Wgs84Transformer.php Co-authored-by: Mikkel Ricky --- src/Geo/Wgs84Transformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php index 4894555..3d46433 100644 --- a/src/Geo/Wgs84Transformer.php +++ b/src/Geo/Wgs84Transformer.php @@ -107,7 +107,7 @@ public function point(string $srid, float $x, float $y): array * * @return array{type: string, coordinates: mixed} */ - public function geometry(string $srid, array $geometry): array + public function transformGeometry(string $srid, array $geometry): array { $type = $geometry['type'] ?? null; From b94942983324a5f1942423f6f0fa2583a6922ac4 Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:57:01 +0200 Subject: [PATCH 36/59] Update src/Geo/Wgs84Transformer.php Co-authored-by: Mikkel Ricky --- src/Geo/Wgs84Transformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php index 3d46433..84b7593 100644 --- a/src/Geo/Wgs84Transformer.php +++ b/src/Geo/Wgs84Transformer.php @@ -129,7 +129,7 @@ public function transformGeometry(string $srid, array $geometry): array * * @return array */ - private function transformCoordinates(string $srid, mixed $coordinates): array + private function transformCoordinates(string $srid, array $coordinates): array { if (!\is_array($coordinates) || [] === $coordinates) { throw new \InvalidArgumentException('GeoJSON coordinates must be a non-empty array.'); From 684f13e31053478fc2ccaa433f102408bac3df02 Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:23:26 +0200 Subject: [PATCH 37/59] Update src/Ngsi/NgsiEntity.php Co-authored-by: Mikkel Ricky --- src/Ngsi/NgsiEntity.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ngsi/NgsiEntity.php b/src/Ngsi/NgsiEntity.php index 6f38391..dbc2f79 100644 --- a/src/Ngsi/NgsiEntity.php +++ b/src/Ngsi/NgsiEntity.php @@ -30,7 +30,7 @@ public function id(): string * because the source data uses "" for "not filled in" and a broker would * otherwise store the emptiness as a fact. */ - public function property(string $name, mixed $value, ?string $observedAt = null): self + public function setProperty(string $name, mixed $value, ?string $observedAt = null): self { if (null === $value || '' === $value || [] === $value) { return $this; From 43ef3ac7821fe542185cd9156100a0b8cdfdbd80 Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:23:56 +0200 Subject: [PATCH 38/59] Update src/Source/SourceInterface.php Co-authored-by: Mikkel Ricky --- src/Source/SourceInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Source/SourceInterface.php b/src/Source/SourceInterface.php index 978c5ce..b372d28 100644 --- a/src/Source/SourceInterface.php +++ b/src/Source/SourceInterface.php @@ -19,7 +19,7 @@ interface SourceInterface { /** - * Identifier used to select this source on the command line. + * Unique identifier for this source. */ public function key(): string; From 21d75592a1122f46a1647720e99579871f74707e Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:26:46 +0200 Subject: [PATCH 39/59] Update src/Source/FeedReader.php Co-authored-by: Mikkel Ricky --- src/Source/FeedReader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Source/FeedReader.php b/src/Source/FeedReader.php index 6a9c280..d2b6400 100644 --- a/src/Source/FeedReader.php +++ b/src/Source/FeedReader.php @@ -9,7 +9,7 @@ /** * Fetches a feed over HTTP and decodes it. */ -final readonly class FeedReader +final readonly class DataSourceReader { public function __construct( private HttpClientInterface $client, From 93897e33ae0f1ac374a5325a52eb2e5190f06b2c Mon Sep 17 00:00:00 2001 From: Jeppe Julius Krogh <106669866+jeppekroghitk@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:28:32 +0200 Subject: [PATCH 40/59] Update src/Source/FeedReader.php Co-authored-by: Mikkel Ricky --- src/Source/FeedReader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Source/FeedReader.php b/src/Source/FeedReader.php index d2b6400..c84ce03 100644 --- a/src/Source/FeedReader.php +++ b/src/Source/FeedReader.php @@ -23,7 +23,7 @@ public function __construct( * be fetched, does not contain valid JSON, or * does not decode to an array */ - public function read(string $location): array + public function read(string $url): array { if (!str_starts_with($location, 'http://') && !str_starts_with($location, 'https://')) { throw new \RuntimeException(\sprintf('Feed location must be an http(s) URL, got "%s".', $location)); From 2a56d2ca3cb2122e0c3d18f20eba7db4a7a6bea4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 14:34:56 +0200 Subject: [PATCH 41/59] Renamed FeedReader to DataSourceReader --- src/Source/{FeedReader.php => DataSourceReader.php} | 0 src/Source/MtmSpatialMaps/HandicapParking.php | 4 ++-- .../{FeedReaderTest.php => DataSourceReaderTest.php} | 8 ++++---- tests/Source/MtmSpatialMaps/HandicapParkingTest.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/Source/{FeedReader.php => DataSourceReader.php} (100%) rename tests/Source/{FeedReaderTest.php => DataSourceReaderTest.php} (94%) diff --git a/src/Source/FeedReader.php b/src/Source/DataSourceReader.php similarity index 100% rename from src/Source/FeedReader.php rename to src/Source/DataSourceReader.php diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index bf7dead..441a452 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -6,7 +6,7 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; -use App\Source\FeedReader; +use App\Source\DataSourceReader; use App\Source\SourceCatalog; use App\Source\SourceDescriptor; use App\Source\SourceInterface; @@ -22,7 +22,7 @@ private const string KEY = 'mtm_spatialmaps-handicap-parking'; public function __construct( - private FeedReader $reader, + private DataSourceReader $reader, private Wgs84Transformer $transformer, private SourceCatalog $catalog, ) { diff --git a/tests/Source/FeedReaderTest.php b/tests/Source/DataSourceReaderTest.php similarity index 94% rename from tests/Source/FeedReaderTest.php rename to tests/Source/DataSourceReaderTest.php index 8d925b8..9c866ca 100644 --- a/tests/Source/FeedReaderTest.php +++ b/tests/Source/DataSourceReaderTest.php @@ -4,12 +4,12 @@ namespace App\Tests\Source; -use App\Source\FeedReader; +use App\Source\DataSourceReader; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; -class FeedReaderTest extends TestCase +class DataSourceReaderTest extends TestCase { /** * Content-agnostic on purpose: a minimal FeatureCollection with meaningless @@ -18,9 +18,9 @@ class FeedReaderTest extends TestCase */ private const string FEATURE_COLLECTION = '{"type":"FeatureCollection","features":[{"example":1},{"example":2}]}'; - private function reader(?MockHttpClient $client = null): FeedReader + private function reader(?MockHttpClient $client = null): DataSourceReader { - return new FeedReader($client ?? new MockHttpClient()); + return new DataSourceReader($client ?? new MockHttpClient()); } public function testItReadsFromAnHttpUrl(): void diff --git a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php index e0065a7..4a67b14 100644 --- a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php +++ b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php @@ -6,7 +6,7 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; -use App\Source\FeedReader; +use App\Source\DataSourceReader; use App\Source\MtmSpatialMaps\HandicapParking; use App\Source\SourceCatalog; use App\Source\SourceDescriptor; @@ -40,7 +40,7 @@ protected function setUp(): void return new MockResponse(json_encode($this->feed(), \JSON_THROW_ON_ERROR)); }); - $source = new HandicapParking(new FeedReader($client), new Wgs84Transformer(), $catalog); + $source = new HandicapParking(new DataSourceReader($client), new Wgs84Transformer(), $catalog); $this->entities = array_map( static fn (NgsiEntity $entity): array => $entity->toArray(['https://example.com/context.jsonld']), From e4452fd003532f507c34816b785a529f461da2ef Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 14:47:03 +0200 Subject: [PATCH 42/59] Validated the source manifest as Symfony config Replaces the hand-written field checks with a config tree, and reads the whole manifest during container warm-up, so a malformed entry fails the build instead of the one import that happens to select it. --- CHANGELOG.md | 2 + composer.json | 1 + composer.lock | 2 +- src/Source/SourceCatalog.php | 136 +++++++-------------- src/Source/SourceCatalogWarmer.php | 38 ++++++ src/Source/SourceManifestConfiguration.php | 115 +++++++++++++++++ tests/Source/SourceCatalogTest.php | 91 +++++++++++++- tests/Source/SourceCatalogWarmerTest.php | 86 +++++++++++++ 8 files changed, 376 insertions(+), 95 deletions(-) create mode 100644 src/Source/SourceCatalogWarmer.php create mode 100644 src/Source/SourceManifestConfiguration.php create mode 100644 tests/Source/SourceCatalogWarmerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b089b68..e6e1c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,5 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Import command that reads a geospatial feed, reprojects it to WGS84 and upserts it to an NGSI-LD broker. * A committed record per data set — feed, CRS, model, DCAT-AP metadata. * An extension point for adding data sets, a test suite, and architecture decision records. + * Source manifest validated against a Symfony config tree and read during container warm-up, so a malformed + entry fails the build rather than the one import that selects it. [Unreleased]: https://github.com/itk-dev/enter diff --git a/composer.json b/composer.json index b3bcf60..fad8d55 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "proj4php/proj4php": "^2.0", "symfony/asset": "~8.1.0", "symfony/asset-mapper": "~8.1.0", + "symfony/config": "~8.1.0", "symfony/console": "~8.1.6", "symfony/dotenv": "~8.1.6", "symfony/flex": "^2", diff --git a/composer.lock b/composer.lock index 2b3d45f..2d8ddb4 100644 --- a/composer.lock +++ b/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": "89753fcad5de669970a5e88087a1a523", + "content-hash": "dfaceee7643a3761619ee1e8ae943e6a", "packages": [ { "name": "composer/semver", diff --git a/src/Source/SourceCatalog.php b/src/Source/SourceCatalog.php index cdcd9f9..2b385df 100644 --- a/src/Source/SourceCatalog.php +++ b/src/Source/SourceCatalog.php @@ -4,6 +4,8 @@ namespace App\Source; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -11,10 +13,13 @@ /** * The manifest of data sets this application publishes. * - * Parsed on first use rather than during container warm-up, so a malformed - * entry fails the import that needs it instead of every cache clear. + * The record's shape is a Symfony config tree rather than hand-written checks, + * and SourceCatalogWarmer reads the whole manifest during container warm-up, so + * a malformed entry fails the build instead of waiting for the one import that + * happens to select it. * * @see config/sources.yaml + * @see SourceManifestConfiguration * @see docs/adr/007-source-manifest.md */ final class SourceCatalog @@ -57,113 +62,60 @@ public function all(): array */ private function load(): array { - if (!is_file($this->manifest)) { - throw new \RuntimeException(\sprintf('Source manifest "%s" does not exist.', $this->manifest)); - } - - try { - $parsed = Yaml::parseFile($this->manifest); - } catch (ParseException $exception) { - throw new \RuntimeException(\sprintf('Source manifest "%s" is not valid YAML: %s', $this->manifest, $exception->getMessage()), previous: $exception); - } - - // Without this the wrong shape yields an empty catalogue, which reads - // as "no data sets are registered" rather than as a broken file. - $sources = \is_array($parsed) ? $parsed['sources'] ?? null : null; - if (!\is_array($sources)) { - throw new \RuntimeException(\sprintf('Source manifest "%s" must contain a "sources" mapping at the top level.', $this->manifest)); - } - $descriptors = []; - foreach ($sources as $key => $entry) { - $key = (string) $key; - if (!\is_array($entry)) { - throw new \RuntimeException(\sprintf('Entry "%s" in %s must be a mapping, got %s.', $key, $this->manifest, get_debug_type($entry))); - } - - $descriptors[$key] = $this->descriptor($key, $entry); + foreach ($this->validated() as $key => $entry) { + $descriptors[$key] = new SourceDescriptor( + key: $key, + title: $entry['title'], + accessUrl: $entry['access_url'], + crs: $entry['crs'], + model: $entry['model'], + description: $entry['description'], + publisher: $entry['publisher'], + contact: $entry['contact'], + landingPage: $entry['landing_page'], + mediaType: $entry['media_type'], + updateFrequency: $entry['update_frequency'], + licence: $entry['licence'], + omittedFields: $entry['omitted_fields'], + ); } return $descriptors; } /** - * @param array $entry - */ - private function descriptor(string $key, array $entry): SourceDescriptor - { - return new SourceDescriptor( - key: $key, - title: $this->required($key, $entry, 'title'), - accessUrl: $this->required($key, $entry, 'access_url'), - crs: $this->required($key, $entry, 'crs'), - model: $this->required($key, $entry, 'model'), - description: $this->optional($key, $entry, 'description'), - publisher: $this->optional($key, $entry, 'publisher'), - contact: $this->optional($key, $entry, 'contact'), - landingPage: $this->optional($key, $entry, 'landing_page'), - mediaType: $this->optional($key, $entry, 'media_type'), - updateFrequency: $this->optional($key, $entry, 'update_frequency'), - licence: $this->optional($key, $entry, 'licence'), - omittedFields: $this->omittedFields($key, $entry), - ); - } - - /** - * @param array $entry - */ - private function required(string $key, array $entry, string $field): string - { - return $this->optional($key, $entry, $field) - ?? throw new \RuntimeException(\sprintf('Entry "%s" in %s is missing the required field "%s"; an import cannot run without it.', $key, $this->manifest, $field)); - } - - /** - * An empty value means "not filled in", the same as an absent key, so both - * become null rather than an empty string. - * - * @param array $entry + * @return array}> */ - private function optional(string $key, array $entry, string $field): ?string + private function validated(): array { - $value = $entry[$field] ?? null; - - if (null === $value || '' === $value) { - return null; + if (!is_file($this->manifest)) { + throw new \RuntimeException(\sprintf('Source manifest "%s" does not exist.', $this->manifest)); } - if (!is_scalar($value)) { - throw new \RuntimeException(\sprintf('Field "%s" of entry "%s" in %s must be a single value, got %s.', $field, $key, $this->manifest, get_debug_type($value))); + try { + $parsed = Yaml::parseFile($this->manifest); + } catch (ParseException $exception) { + throw new \RuntimeException(\sprintf('Source manifest "%s" is not valid YAML: %s', $this->manifest, $exception->getMessage()), previous: $exception); } - return trim((string) $value); - } - - /** - * @param array $entry - * - * @return array - */ - private function omittedFields(string $key, array $entry): array - { - $omitted = $entry['omitted_fields'] ?? []; - - if (!\is_array($omitted)) { - throw new \RuntimeException(\sprintf('Field "omitted_fields" of entry "%s" in %s must map each field to the reason it is not published.', $key, $this->manifest)); + // The tree is rooted at the entries themselves, so it never sees the + // key holding them. Without this the wrong shape yields an empty + // catalogue, which reads as "no data sets are registered" rather than + // as a broken file. + $sources = \is_array($parsed) ? $parsed['sources'] ?? null : null; + if (!\is_array($sources)) { + throw new \RuntimeException(\sprintf('Source manifest "%s" must contain a "sources" mapping at the top level.', $this->manifest)); } - $reasons = []; - foreach ($omitted as $field => $reason) { - // The reason is the half of the record that cannot be recovered - // from the code, so a bare list of names is not accepted. - if (!\is_string($reason) || '' === trim($reason)) { - throw new \RuntimeException(\sprintf('Omitted field "%s" of entry "%s" in %s needs a reason.', $field, $key, $this->manifest)); - } - - $reasons[(string) $field] = trim($reason); + try { + /** @var array}> $processed */ + $processed = new Processor()->processConfiguration(new SourceManifestConfiguration(), [$sources]); + } catch (InvalidConfigurationException $exception) { + throw new \RuntimeException(\sprintf('Source manifest "%s" is invalid: %s', $this->manifest, $exception->getMessage()), previous: $exception); } - return $reasons; + return $processed; } } diff --git a/src/Source/SourceCatalogWarmer.php b/src/Source/SourceCatalogWarmer.php new file mode 100644 index 0000000..c262f5d --- /dev/null +++ b/src/Source/SourceCatalogWarmer.php @@ -0,0 +1,38 @@ +catalog->all(); + + return []; + } +} diff --git a/src/Source/SourceManifestConfiguration.php b/src/Source/SourceManifestConfiguration.php new file mode 100644 index 0000000..05383d6 --- /dev/null +++ b/src/Source/SourceManifestConfiguration.php @@ -0,0 +1,115 @@ +getRootNode() + // The import selects a data set by its key exactly as written in + // the manifest. Key normalization rewrites a key that contains + // dashes and no underscore, which breaks that lookup silently. + ->normalizeKeys(false) + ->requiresAtLeastOneElement() + ->arrayPrototype() + ->beforeNormalization() + ->always(self::blankToNull()) + ->end() + ->children() + ->scalarNode('title') + ->isRequired() + ->cannotBeEmpty() + ->info('What the data set is called where it is published.') + ->end() + ->scalarNode('access_url') + ->isRequired() + ->cannotBeEmpty() + ->info('Where the feed is read from; an import cannot run without it.') + ->end() + ->scalarNode('crs') + ->isRequired() + ->cannotBeEmpty() + ->info('The CRS the feed publishes coordinates in, e.g. "EPSG:25832". A wrong value yields well-formed coordinates in the wrong place.') + ->end() + ->scalarNode('model') + ->isRequired() + ->cannotBeEmpty() + ->info('Smart Data Model the data set is published as.') + ->end() + ->scalarNode('description')->defaultNull()->end() + ->scalarNode('publisher')->defaultNull()->end() + ->scalarNode('contact')->defaultNull()->end() + ->scalarNode('landing_page')->defaultNull()->end() + ->scalarNode('media_type')->defaultNull()->end() + ->scalarNode('update_frequency')->defaultNull()->end() + ->scalarNode('licence') + ->defaultNull() + ->info('Empty means the terms are unsettled; DCAT-AP requires one before the data set can be registered.') + ->end() + ->arrayNode('omitted_fields') + ->defaultValue([]) + ->normalizeKeys(false) + ->scalarPrototype() + ->beforeNormalization() + ->always(self::blankToNullValue()) + ->end() + ->cannotBeEmpty() + ->info('The reason a field is withheld cannot be recovered from the code, so a bare list of names is not accepted.') + ->end() + ->end() + ->end() + ->end(); + + return $tree; + } + + /** + * An empty value means "not filled in", the same as an absent key, so both + * become null rather than an empty string. + * + * Normalization runs before the type check, so an entry that is not a + * mapping has to pass through untouched for the tree to report it as one. + */ + private static function blankToNull(): \Closure + { + return static function (mixed $entry): mixed { + if (!\is_array($entry)) { + return $entry; + } + + return array_map(self::blankToNullValue(), $entry); + }; + } + + private static function blankToNullValue(): \Closure + { + return static function (mixed $value): mixed { + if (!\is_string($value)) { + return $value; + } + + $value = trim($value); + + return '' === $value ? null : $value; + }; + } +} diff --git a/tests/Source/SourceCatalogTest.php b/tests/Source/SourceCatalogTest.php index e5f1dbd..61a0f57 100644 --- a/tests/Source/SourceCatalogTest.php +++ b/tests/Source/SourceCatalogTest.php @@ -84,7 +84,7 @@ public function testItRejectsAnEntryMissingAFieldTheImportNeeds(): void YAML)); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('missing the required field "crs"'); + $this->expectExceptionMessage('The child config "crs" under "sources.a-source" must be configured'); $catalog->all(); } @@ -103,11 +103,98 @@ public function testItRejectsAnOmittedFieldWithoutAReason(): void YAML)); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('needs a reason'); + $this->expectExceptionMessage('"sources.a-source.omitted_fields.some_field" cannot contain an empty value'); $catalog->all(); } + /** + * The import selects a data set by the key written in the manifest, and the + * config tree rewrites a key that has dashes and no underscore unless told + * otherwise. A rewritten key stops matching without saying so. + */ + public function testItKeepsADashedSourceKeyIntact(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + handicap-parking: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + YAML)); + + $this->assertSame(['handicap-parking'], array_keys($catalog->all())); + $this->assertSame('handicap-parking', $catalog->get('handicap-parking')->key); + } + + /** + * A misspelled optional field was dropped in silence before, which loses a + * fact the record exists to carry. + */ + public function testItRejectsAFieldTheManifestDoesNotDefine(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + license: CC-BY-4.0 + YAML)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unrecognized option "license" under "sources.a-source"'); + + $catalog->all(); + } + + public function testItRejectsAnEntryThatIsNotAMapping(): void + { + $catalog = new SourceCatalog($this->manifest("sources:\n a-source: just a string\n")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid type for path "sources.a-source"'); + + $catalog->all(); + } + + public function testItRejectsAManifestThatRegistersNothing(): void + { + $catalog = new SourceCatalog($this->manifest("sources: {}\n")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('should have at least 1 element'); + + $catalog->all(); + } + + /** + * An empty value means "not filled in", the same as an absent key, so an + * unanswered question reads the same either way. + */ + public function testItReadsABlankOptionalFieldAsUnknown(): void + { + $catalog = new SourceCatalog($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + publisher: ' Aarhus Kommune ' + contact: '' + licence: ~ + YAML)); + + $descriptor = $catalog->get('a-source'); + + $this->assertSame('Aarhus Kommune', $descriptor->publisher); + $this->assertNull($descriptor->contact); + $this->assertNull($descriptor->licence); + } + public function testItReportsAManifestThatIsNotThere(): void { $catalog = new SourceCatalog('/no/such/sources.yaml'); diff --git a/tests/Source/SourceCatalogWarmerTest.php b/tests/Source/SourceCatalogWarmerTest.php new file mode 100644 index 0000000..02294d7 --- /dev/null +++ b/tests/Source/SourceCatalogWarmerTest.php @@ -0,0 +1,86 @@ + */ + private array $written = []; + + protected function tearDown(): void + { + foreach ($this->written as $path) { + if (is_file($path)) { + unlink($path); + } + } + + $this->written = []; + } + + /** + * An optional warmer can be skipped, and a manifest that is only read when + * an import selects it is exactly what warming exists to avoid. + */ + public function testItIsNotOptional(): void + { + $this->assertFalse($this->warmer(\dirname(__DIR__, 2).'/config/sources.yaml')->isOptional()); + } + + public function testItWarmsTheShippedManifestWithoutPreloadingAnything(): void + { + $warmer = $this->warmer(\dirname(__DIR__, 2).'/config/sources.yaml'); + + $this->assertSame([], $warmer->warmUp(sys_get_temp_dir(), sys_get_temp_dir())); + } + + /** + * The reason for warming at all: an entry no import selects still fails the + * build rather than waiting to be discovered. + */ + public function testItFailsOnAnEntryNoImportWouldReach(): void + { + $warmer = $this->warmer($this->manifest(<<<'YAML' + sources: + a-source: + title: A source + access_url: https://example.com/feed.json + crs: EPSG:25832 + model: Example + unreached-source: + title: Another source + access_url: https://example.com/other.json + model: Example + YAML)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The child config "crs" under "sources.unreached-source" must be configured'); + + $warmer->warmUp(sys_get_temp_dir(), sys_get_temp_dir()); + } + + private function warmer(string $manifest): SourceCatalogWarmer + { + return new SourceCatalogWarmer(new SourceCatalog($manifest)); + } + + private function manifest(string $yaml): string + { + $path = tempnam(sys_get_temp_dir(), 'sources-'); + + if (false === $path) { + $this->fail('Could not create a temporary manifest.'); + } + + file_put_contents($path, $yaml); + $this->written[] = $path; + + return $path; + } +} From 02cc6cf683bcb7de4ae9e1db665ef56c385c63a2 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 14:52:07 +0200 Subject: [PATCH 43/59] Fixed error in pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e39b258..333ba2f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,3 @@ - Date: Mon, 7 Sep 2026 15:42:52 +0200 Subject: [PATCH 44/59] Corrected adr 007 --- docs/adr/007-source-manifest.md | 128 ++++++++++---------------------- 1 file changed, 41 insertions(+), 87 deletions(-) diff --git a/docs/adr/007-source-manifest.md b/docs/adr/007-source-manifest.md index d644c8f..7491f71 100644 --- a/docs/adr/007-source-manifest.md +++ b/docs/adr/007-source-manifest.md @@ -10,109 +10,63 @@ ## Context -Each published data set carries facts the import needs — where the feed is read -from, the coordinate reference system its coordinates are in, and the model it -is published as — and facts only people need: who owns the data, on what terms -it may be republished, how often it changes, and which of its fields are -deliberately not published, with the reason for each. +This application publishes data sets it does not own. Each needs a +description — where it comes from, how to read it, who owns it, on what terms +it may be republished. -The first group must be readable by code. The second is what a public data -portal requires at registration, and what a data owner asks for when -establishing what happened to their data. Both grow with the number of data -sets. - -Recording the two groups separately produces a machine-readable value and a -written description of the same value, which can then disagree. Recording only -the first leaves the rest unwritten, and the questions it answers are then -answered from memory. - -This ADR serves to decide where a data set's own facts are recorded. +This ADR serves to decide where the data sets and their descriptions live. ### Drivers -- **Functional:** the code reads the facts it needs from the same record a - person reads; a data set is registerable on a public portal without a fresh - survey; an incomplete record fails the import that needs it rather than - producing an incomplete publication. -- **Non-functional:** adding a data set requires no deployment change; the - record is reviewable as a diff; no fact exists in two places. +- **Functional:** each data set is listed with what is needed to read and + republish it. +- **Non-functional:** adding a data set needs no deployment change, and its + description is reviewable as a diff. ### Options Considered -1. **One environment variable per data set.** Follows the convention that - configuration belongs in the environment, and lets a value differ per - environment. But variable names grow with the catalogue, so each new data - set becomes a deployment change; the environment carries strings only, - leaving metadata beyond an address nowhere to live; and values are invisible - in review, so a wrong one is found by running the import. -2. **Every fact in the class that maps the data set.** Nothing can diverge, - there being one copy, and the language enforces its presence. But metadata - is then readable only by opening code, extracting it for portal registration - requires writing an extractor, and correcting a licence or a contact becomes - a code change reviewed as one. -3. **A committed manifest the code reads.** One record per data set, keyed by - the identifier the import selects it with, holding the facts the code needs - beside those it does not, in a shape a catalogue profile can be generated - from. Validating the record becomes work of our own, and the values are - identical in every environment. +1. **One environment variable per data set.** Variable names grow with the + catalogue, and the environment holds only strings. +2. **Every fact in the class that maps the data set.** Nothing can diverge, but + the description is readable only by opening code, and correcting a licence + becomes a code change. +3. **A committed manifest the code reads.** One record per data set, holding + the description beside the values the import needs; its shape has to be + declared. 4. **An external catalogue or registry service.** The eventual home of - published metadata, with search and harvesting already built. But it has to - be running for an import to work, it is a second system to operate, and it - must be populated before anything can be published from it — from records - that would have to live somewhere else in the meantime. + published metadata, but a second system to operate, populated before + anything can be published from it. ## Decision -Record each data set in a **committed manifest**, keyed by the identifier the -import selects it with, and read from it every fact the code needs. - -Four rules follow: - -1. **Record only what the code cannot state.** How a feed's fields map onto the - model, and every quirk of its shape, stay in the class that performs the - mapping. Restating them in the manifest recreates the divergence the - manifest exists to prevent. -2. **A fact both the code and a reader need is read from the manifest.** It is - not also written in code, in a comment, or in the README. -3. **An incomplete or malformed record is an error.** Fields an import cannot - run without are required, and their absence raises rather than defaulting. A - fact that is unknown is recorded as unknown, so the gap stays visible. -4. **Name the fields after the catalogue profile the data will be registered - under** — DCAT-AP — so that publication is a translation rather than a - redesign. - -Rationale: - -- What has to be prevented is a value diverging from its description, so the - two belong to the same record. -- Portal registration and answering a data owner need the same fields, and - neither can be derived from mapping code. -- A record is reviewed alongside the class that consumes it, so a reviewer sees - the address, the reference system and the model together with the mapping - that assumes them. -- Where a feed is read from is a fact about the data set, not about the machine - running the import, so the environment is the wrong place for it. +Keep one **committed manifest** listing every data set this application +publishes, keyed by the identifier the import selects a data set by, and read +from it every fact the code needs. + +1. **Record only what the code cannot state.** Field mappings and feed quirks + stay in the class that maps the data set. +2. **A fact the manifest records is not restated elsewhere**, in code, a + comment, or documentation. +3. **An incomplete record is an error.** Required fields raise rather than + default, an unknown fact is recorded as unknown, and the shape is a schema + the framework validates when the application is built. +4. **Name the fields after DCAT-AP**, the profile the data is registered under, + so publication is a translation. ## Consequences ### Positive -- One record per data set, reviewable as a diff and versioned with the code. -- Adding a data set is one class and one record, with no deployment change. -- Registration on a public portal is a translation of records that exist. -- Licence, ownership and what was withheld have a single answer, and an - unanswered question shows as an empty value rather than as nothing at all. -- The same records can generate catalogue entities later without introducing a - second source of truth. +- Every data set is listed in one place, reviewable as a diff and versioned + with the code. +- Portal registration and owner questions translate records that already exist. +- A wrong record, or a field the manifest does not define, fails the build. ### Negative / Trade-offs -- **Values are identical in every environment.** Pointing a data set at a copy - for testing means editing a committed file. That is consistent with reading - feeds where they live, but it removes an escape hatch the environment offered. -- **A wrong reference system or model in the manifest is as damaging as a wrong - one in code, while looking less like code.** A wrong reference system yields - coordinates that are well-formed and in the wrong place. -- **Fields no code reads have nothing keeping them current.** Until catalogue - entities are generated from them, only review does. -- Validating the record is work the environment did not require. +- Values are identical in every environment, so pointing a data set at a test + copy means editing a committed file. +- A wrong reference system looks less like code than it is, and yields + coordinates that are well-formed and misplaced. +- A malformed record blocks every build, not just the import that reads it. +- Fields no code reads have only review keeping them current. From 538ae480723af9d2184a460fb49ed6bd97004c82 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 15:48:02 +0200 Subject: [PATCH 45/59] ADR 007 edits --- docs/adr/007-source-manifest.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/adr/007-source-manifest.md b/docs/adr/007-source-manifest.md index 7491f71..47fcea2 100644 --- a/docs/adr/007-source-manifest.md +++ b/docs/adr/007-source-manifest.md @@ -10,31 +10,28 @@ ## Context -This application publishes data sets it does not own. Each needs a -description — where it comes from, how to read it, who owns it, on what terms -it may be republished. - -This ADR serves to decide where the data sets and their descriptions live. +This application publishes data sets it does not own. This ADR serves +to decide where the data sets and their specifications live. ### Drivers - **Functional:** each data set is listed with what is needed to read and republish it. - **Non-functional:** adding a data set needs no deployment change, and its - description is reviewable as a diff. + specification is reviewable as a diff. ### Options Considered 1. **One environment variable per data set.** Variable names grow with the - catalogue, and the environment holds only strings. + collection, and the environment holds only strings. 2. **Every fact in the class that maps the data set.** Nothing can diverge, but - the description is readable only by opening code, and correcting a licence + the specification is readable only by opening code, and correcting a licence becomes a code change. 3. **A committed manifest the code reads.** One record per data set, holding - the description beside the values the import needs; its shape has to be + the specification beside the values the import needs; its shape has to be declared. 4. **An external catalogue or registry service.** The eventual home of - published metadata, but a second system to operate, populated before + published specifications, but a second system to operate, populated before anything can be published from it. ## Decision @@ -50,8 +47,8 @@ from it every fact the code needs. 3. **An incomplete record is an error.** Required fields raise rather than default, an unknown fact is recorded as unknown, and the shape is a schema the framework validates when the application is built. -4. **Name the fields after DCAT-AP**, the profile the data is registered under, - so publication is a translation. +4. **Structure each record after DCAT-AP**, the specification the data will be + published under, so publication is a translation rather than a redesign. ## Consequences @@ -59,7 +56,8 @@ from it every fact the code needs. - Every data set is listed in one place, reviewable as a diff and versioned with the code. -- Portal registration and owner questions translate records that already exist. +- Publishing a specification onward, and answering the data owner, translate + records that already exist. - A wrong record, or a field the manifest does not define, fails the build. ### Negative / Trade-offs From cda1ba2f26b6e94d51f6b9d35a26c6c5692962b0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 15:48:51 +0200 Subject: [PATCH 46/59] Raname calls to renamed methods --- src/Geo/Wgs84Transformer.php | 4 +++- src/Source/DataSourceReader.php | 10 +++++----- src/Source/MtmSpatialMaps/HandicapParking.php | 12 ++++++------ tests/Command/ImportCommandTest.php | 2 +- tests/Geo/Wgs84TransformerTest.php | 14 +++++++------- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php index 84b7593..9f57abe 100644 --- a/src/Geo/Wgs84Transformer.php +++ b/src/Geo/Wgs84Transformer.php @@ -127,11 +127,13 @@ public function transformGeometry(string $srid, array $geometry): array * of those for Polygon, and so on. Recursing until the first element is * numeric handles every depth without enumerating the types. * + * @param array $coordinates + * * @return array */ private function transformCoordinates(string $srid, array $coordinates): array { - if (!\is_array($coordinates) || [] === $coordinates) { + if ([] === $coordinates) { throw new \InvalidArgumentException('GeoJSON coordinates must be a non-empty array.'); } diff --git a/src/Source/DataSourceReader.php b/src/Source/DataSourceReader.php index c84ce03..c8f3751 100644 --- a/src/Source/DataSourceReader.php +++ b/src/Source/DataSourceReader.php @@ -25,23 +25,23 @@ public function __construct( */ public function read(string $url): array { - if (!str_starts_with($location, 'http://') && !str_starts_with($location, 'https://')) { - throw new \RuntimeException(\sprintf('Feed location must be an http(s) URL, got "%s".', $location)); + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + throw new \RuntimeException(\sprintf('Feed location must be an http(s) URL, got "%s".', $url)); } - $json = $this->fetch($location); + $json = $this->fetch($url); try { $decoded = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { - throw new \RuntimeException(\sprintf('Invalid JSON in "%s": %s', $location, $exception->getMessage()), previous: $exception); + throw new \RuntimeException(\sprintf('Invalid JSON in "%s": %s', $url, $exception->getMessage()), previous: $exception); } // A JSON document may legally be a scalar. Every feed we consume is a // list or an object, and a scalar here means the location is wrong // rather than that the feed is empty. if (!\is_array($decoded)) { - throw new \RuntimeException(\sprintf('Expected a JSON array or object in "%s", got %s.', $location, get_debug_type($decoded))); + throw new \RuntimeException(\sprintf('Expected a JSON array or object in "%s", got %s.', $url, get_debug_type($decoded))); } return $decoded; diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 441a452..27bcdcd 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -78,12 +78,12 @@ private function toEntity(array $feature, SourceDescriptor $source): ?NgsiEntity // models have separate category enums, so values are not interchangeable. // `onStreet` is dropped because the entity type already states it. return $entity - ->property('name', $this->address($row)) - ->property('description', trim((string) ($row['bemrk'] ?? ''))) - ->property('category', ['forDisabled']) - ->property('totalSpotNumber', (int) ($row['invalidepladser'] ?? 0)) - ->property('source', $source->accessUrl) - ->geoProperty('location', $this->transformer->geometry($source->crs, $geometry)); + ->setProperty('name', $this->address($row)) + ->setProperty('description', trim((string) ($row['bemrk'] ?? ''))) + ->setProperty('category', ['forDisabled']) + ->setProperty('totalSpotNumber', (int) ($row['invalidepladser'] ?? 0)) + ->setProperty('source', $source->accessUrl) + ->geoProperty('location', $this->transformer->transformGeometry($source->crs, $geometry)); } /** diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php index ae858a3..a1d68ad 100644 --- a/tests/Command/ImportCommandTest.php +++ b/tests/Command/ImportCommandTest.php @@ -121,7 +121,7 @@ public function testItRejectsAnUnknownSource(): void public function testDryRunPrintsThePayloadAndSendsNothing(): void { $entity = new NgsiEntity('urn:ngsi-ld:Example:1', 'Example') - ->property('name', 'Example'); + ->setProperty('name', 'Example'); $tester = $this->tester([$this->source('one-entity', $entity)]); diff --git a/tests/Geo/Wgs84TransformerTest.php b/tests/Geo/Wgs84TransformerTest.php index aacf039..2f2192b 100644 --- a/tests/Geo/Wgs84TransformerTest.php +++ b/tests/Geo/Wgs84TransformerTest.php @@ -93,7 +93,7 @@ public function testItAcceptsAdditionalDefinitions(): void public function testItReprojectsAPointGeometry(): void { - $geometry = $this->transformer->geometry(self::UTM32, [ + $geometry = $this->transformer->transformGeometry(self::UTM32, [ 'type' => 'Point', 'coordinates' => [self::REFERENCE_EASTING, self::REFERENCE_NORTHING], ]); @@ -104,7 +104,7 @@ public function testItReprojectsAPointGeometry(): void public function testItReprojectsALineString(): void { - $geometry = $this->transformer->geometry(self::UTM32, [ + $geometry = $this->transformer->transformGeometry(self::UTM32, [ 'type' => 'LineString', 'coordinates' => [ [574108.2557507273, 6222343.6199512165], @@ -120,7 +120,7 @@ public function testItReprojectsALineString(): void public function testItReprojectsAPolygonPreservingNesting(): void { - $geometry = $this->transformer->geometry(self::UTM32, [ + $geometry = $this->transformer->transformGeometry(self::UTM32, [ 'type' => 'Polygon', 'coordinates' => [ [ @@ -147,7 +147,7 @@ public function testItReprojectsAMultiPolygon(): void [574108.0, 6222343.0], ]; - $geometry = $this->transformer->geometry(self::UTM32, [ + $geometry = $this->transformer->transformGeometry(self::UTM32, [ 'type' => 'MultiPolygon', 'coordinates' => [[$ring], [$ring]], ]); @@ -160,14 +160,14 @@ public function testItRejectsAGeometryWithoutCoordinates(): void { $this->expectException(\InvalidArgumentException::class); - $this->transformer->geometry(self::UTM32, ['type' => 'Point']); + $this->transformer->transformGeometry(self::UTM32, ['type' => 'Point']); } public function testItRejectsAGeometryCollection(): void { $this->expectException(\InvalidArgumentException::class); - $this->transformer->geometry(self::UTM32, [ + $this->transformer->transformGeometry(self::UTM32, [ 'type' => 'GeometryCollection', 'geometries' => [], ]); @@ -177,6 +177,6 @@ public function testItRejectsAPositionWithASingleOrdinate(): void { $this->expectException(\InvalidArgumentException::class); - $this->transformer->geometry(self::UTM32, ['type' => 'Point', 'coordinates' => [574108.0]]); + $this->transformer->transformGeometry(self::UTM32, ['type' => 'Point', 'coordinates' => [574108.0]]); } } From 365325a037c8bb87612f5ad49dd223b174abd94c Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 7 Sep 2026 15:49:31 +0200 Subject: [PATCH 47/59] Coding standards --- docs/adr/007-source-manifest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/007-source-manifest.md b/docs/adr/007-source-manifest.md index 47fcea2..8bb90c3 100644 --- a/docs/adr/007-source-manifest.md +++ b/docs/adr/007-source-manifest.md @@ -10,7 +10,7 @@ ## Context -This application publishes data sets it does not own. This ADR serves +This application publishes data sets it does not own. This ADR serves to decide where the data sets and their specifications live. ### Drivers From 0ed759862c94c51100aa17f1a57940557278b389 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 09:47:31 +0200 Subject: [PATCH 48/59] Grouped the manifest classes under App\Source\Manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog, Descriptor, Schema and Validator now sit together, which drops the repeated Source prefix. The cache warmer became Validator: it caches nothing, and exists so a malformed record fails the build. Schema no longer implements ConfigurationInterface — that is a bundle extension contract, not a description of a committed data file — so the catalogue hands the built tree to Processor::process() instead. --- .../Catalog.php} | 27 ++++++------- .../Descriptor.php} | 4 +- .../Schema.php} | 13 ++++--- src/Source/Manifest/Validator.php | 34 +++++++++++++++++ src/Source/MtmSpatialMaps/HandicapParking.php | 8 ++-- src/Source/SourceCatalogWarmer.php | 38 ------------------- .../CatalogTest.php} | 32 ++++++++-------- .../ValidatorTest.php} | 34 +++++++++-------- .../MtmSpatialMaps/HandicapParkingTest.php | 8 ++-- 9 files changed, 100 insertions(+), 98 deletions(-) rename src/Source/{SourceCatalog.php => Manifest/Catalog.php} (84%) rename src/Source/{SourceDescriptor.php => Manifest/Descriptor.php} (95%) rename src/Source/{SourceManifestConfiguration.php => Manifest/Schema.php} (92%) create mode 100644 src/Source/Manifest/Validator.php delete mode 100644 src/Source/SourceCatalogWarmer.php rename tests/Source/{SourceCatalogTest.php => Manifest/CatalogTest.php} (85%) rename tests/Source/{SourceCatalogWarmerTest.php => Manifest/ValidatorTest.php} (58%) diff --git a/src/Source/SourceCatalog.php b/src/Source/Manifest/Catalog.php similarity index 84% rename from src/Source/SourceCatalog.php rename to src/Source/Manifest/Catalog.php index 2b385df..41baa06 100644 --- a/src/Source/SourceCatalog.php +++ b/src/Source/Manifest/Catalog.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Source; +namespace App\Source\Manifest; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; @@ -13,18 +13,19 @@ /** * The manifest of data sets this application publishes. * - * The record's shape is a Symfony config tree rather than hand-written checks, - * and SourceCatalogWarmer reads the whole manifest during container warm-up, so - * a malformed entry fails the build instead of waiting for the one import that - * happens to select it. + * The record's shape is declared in Schema rather than checked by hand. Parsing + * is deferred to first use, and Validator reads every record when the + * application is built, so a malformed entry fails the build instead of waiting + * for the one import that happens to select it. * * @see config/sources.yaml - * @see SourceManifestConfiguration + * @see Schema + * @see Validator * @see docs/adr/007-source-manifest.md */ -final class SourceCatalog +final class Catalog { - /** @var array|null */ + /** @var array|null */ private ?array $descriptors = null; public function __construct( @@ -36,7 +37,7 @@ public function __construct( /** * @throws \RuntimeException when the manifest cannot be read, or carries no entry for the key */ - public function get(string $key): SourceDescriptor + public function get(string $key): Descriptor { $descriptors = $this->all(); @@ -48,7 +49,7 @@ public function get(string $key): SourceDescriptor } /** - * @return array keyed by source key + * @return array keyed by source key * * @throws \RuntimeException when the manifest cannot be read */ @@ -58,14 +59,14 @@ public function all(): array } /** - * @return array + * @return array */ private function load(): array { $descriptors = []; foreach ($this->validated() as $key => $entry) { - $descriptors[$key] = new SourceDescriptor( + $descriptors[$key] = new Descriptor( key: $key, title: $entry['title'], accessUrl: $entry['access_url'], @@ -111,7 +112,7 @@ private function validated(): array try { /** @var array}> $processed */ - $processed = new Processor()->processConfiguration(new SourceManifestConfiguration(), [$sources]); + $processed = new Processor()->process(Schema::tree(), [$sources]); } catch (InvalidConfigurationException $exception) { throw new \RuntimeException(\sprintf('Source manifest "%s" is invalid: %s', $this->manifest, $exception->getMessage()), previous: $exception); } diff --git a/src/Source/SourceDescriptor.php b/src/Source/Manifest/Descriptor.php similarity index 95% rename from src/Source/SourceDescriptor.php rename to src/Source/Manifest/Descriptor.php index 83b2b14..34a1e2b 100644 --- a/src/Source/SourceDescriptor.php +++ b/src/Source/Manifest/Descriptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Source; +namespace App\Source\Manifest; /** * One entry from the source manifest: what a data set is, where it is read @@ -15,7 +15,7 @@ * @see config/sources.yaml * @see docs/adr/007-source-manifest.md */ -final readonly class SourceDescriptor +final readonly class Descriptor { /** * @param string $key identifier the import selects this data set by diff --git a/src/Source/SourceManifestConfiguration.php b/src/Source/Manifest/Schema.php similarity index 92% rename from src/Source/SourceManifestConfiguration.php rename to src/Source/Manifest/Schema.php index 05383d6..6d20aa3 100644 --- a/src/Source/SourceManifestConfiguration.php +++ b/src/Source/Manifest/Schema.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace App\Source; +namespace App\Source\Manifest; use Symfony\Component\Config\Definition\Builder\TreeBuilder; -use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\Definition\NodeInterface; /** * The shape of the source manifest, as a Symfony config tree. @@ -15,12 +15,15 @@ * suffixed to that error as a hint, which is the moment a field's rationale is * needed. * + * Deliberately not a ConfigurationInterface: that is a bundle extension's + * contract, and this schema describes a committed data file instead. + * * @see config/sources.yaml * @see docs/adr/007-source-manifest.md */ -final readonly class SourceManifestConfiguration implements ConfigurationInterface +final readonly class Schema { - public function getConfigTreeBuilder(): TreeBuilder + public static function tree(): NodeInterface { $tree = new TreeBuilder('sources'); @@ -79,7 +82,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end(); - return $tree; + return $tree->buildTree(); } /** diff --git a/src/Source/Manifest/Validator.php b/src/Source/Manifest/Validator.php new file mode 100644 index 0000000..01d54dd --- /dev/null +++ b/src/Source/Manifest/Validator.php @@ -0,0 +1,34 @@ + nothing is written, so nothing is preloaded + */ + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + $this->catalog->all(); + + return []; + } +} diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 27bcdcd..b170ff0 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -7,8 +7,8 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; use App\Source\DataSourceReader; -use App\Source\SourceCatalog; -use App\Source\SourceDescriptor; +use App\Source\Manifest\Catalog; +use App\Source\Manifest\Descriptor; use App\Source\SourceInterface; /** @@ -24,7 +24,7 @@ public function __construct( private DataSourceReader $reader, private Wgs84Transformer $transformer, - private SourceCatalog $catalog, + private Catalog $catalog, ) { } @@ -50,7 +50,7 @@ public function entities(): iterable /** * @param array $feature GeoJSON Feature */ - private function toEntity(array $feature, SourceDescriptor $source): ?NgsiEntity + private function toEntity(array $feature, Descriptor $source): ?NgsiEntity { // A Feature keeps its attributes under `properties` and its geometry // beside them, so neither is at the feature's top level. diff --git a/src/Source/SourceCatalogWarmer.php b/src/Source/SourceCatalogWarmer.php deleted file mode 100644 index c262f5d..0000000 --- a/src/Source/SourceCatalogWarmer.php +++ /dev/null @@ -1,38 +0,0 @@ -catalog->all(); - - return []; - } -} diff --git a/tests/Source/SourceCatalogTest.php b/tests/Source/Manifest/CatalogTest.php similarity index 85% rename from tests/Source/SourceCatalogTest.php rename to tests/Source/Manifest/CatalogTest.php index 61a0f57..bfa83b5 100644 --- a/tests/Source/SourceCatalogTest.php +++ b/tests/Source/Manifest/CatalogTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace App\Tests\Source; +namespace App\Tests\Source\Manifest; -use App\Source\SourceCatalog; +use App\Source\Manifest\Catalog; use PHPUnit\Framework\TestCase; -class SourceCatalogTest extends TestCase +class CatalogTest extends TestCase { /** @var list */ private array $written = []; @@ -25,7 +25,7 @@ protected function tearDown(): void public function testTheShippedManifestIsUsable(): void { - $catalog = new SourceCatalog(\dirname(__DIR__, 2).'/config/sources.yaml'); + $catalog = new Catalog(\dirname(__DIR__, 3).'/config/sources.yaml'); $this->assertNotSame([], $catalog->all(), 'The manifest registers no data sets.'); } @@ -36,7 +36,7 @@ public function testTheShippedManifestIsUsable(): void */ public function testEveryShippedEntryCanBeImportedFrom(): void { - $catalog = new SourceCatalog(\dirname(__DIR__, 2).'/config/sources.yaml'); + $catalog = new Catalog(\dirname(__DIR__, 3).'/config/sources.yaml'); foreach ($catalog->all() as $key => $descriptor) { $this->assertSame($key, $descriptor->key); @@ -48,7 +48,7 @@ public function testEveryShippedEntryCanBeImportedFrom(): void public function testItNamesTheKnownEntriesWhenAskedForAnUnknownOne(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -65,7 +65,7 @@ public function testItNamesTheKnownEntriesWhenAskedForAnUnknownOne(): void public function testItRejectsAManifestWithoutASourcesMapping(): void { - $catalog = new SourceCatalog($this->manifest("data_sets:\n a-source: {}\n")); + $catalog = new Catalog($this->manifest("data_sets:\n a-source: {}\n")); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('must contain a "sources" mapping'); @@ -75,7 +75,7 @@ public function testItRejectsAManifestWithoutASourcesMapping(): void public function testItRejectsAnEntryMissingAFieldTheImportNeeds(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -91,7 +91,7 @@ public function testItRejectsAnEntryMissingAFieldTheImportNeeds(): void public function testItRejectsAnOmittedFieldWithoutAReason(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -115,7 +115,7 @@ public function testItRejectsAnOmittedFieldWithoutAReason(): void */ public function testItKeepsADashedSourceKeyIntact(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: handicap-parking: title: A source @@ -134,7 +134,7 @@ public function testItKeepsADashedSourceKeyIntact(): void */ public function testItRejectsAFieldTheManifestDoesNotDefine(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -152,7 +152,7 @@ public function testItRejectsAFieldTheManifestDoesNotDefine(): void public function testItRejectsAnEntryThatIsNotAMapping(): void { - $catalog = new SourceCatalog($this->manifest("sources:\n a-source: just a string\n")); + $catalog = new Catalog($this->manifest("sources:\n a-source: just a string\n")); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid type for path "sources.a-source"'); @@ -162,7 +162,7 @@ public function testItRejectsAnEntryThatIsNotAMapping(): void public function testItRejectsAManifestThatRegistersNothing(): void { - $catalog = new SourceCatalog($this->manifest("sources: {}\n")); + $catalog = new Catalog($this->manifest("sources: {}\n")); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('should have at least 1 element'); @@ -176,7 +176,7 @@ public function testItRejectsAManifestThatRegistersNothing(): void */ public function testItReadsABlankOptionalFieldAsUnknown(): void { - $catalog = new SourceCatalog($this->manifest(<<<'YAML' + $catalog = new Catalog($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -197,7 +197,7 @@ public function testItReadsABlankOptionalFieldAsUnknown(): void public function testItReportsAManifestThatIsNotThere(): void { - $catalog = new SourceCatalog('/no/such/sources.yaml'); + $catalog = new Catalog('/no/such/sources.yaml'); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('does not exist'); @@ -207,7 +207,7 @@ public function testItReportsAManifestThatIsNotThere(): void public function testItReportsUnparsableYaml(): void { - $catalog = new SourceCatalog($this->manifest("sources:\n - [unbalanced\n")); + $catalog = new Catalog($this->manifest("sources:\n - [unbalanced\n")); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('is not valid YAML'); diff --git a/tests/Source/SourceCatalogWarmerTest.php b/tests/Source/Manifest/ValidatorTest.php similarity index 58% rename from tests/Source/SourceCatalogWarmerTest.php rename to tests/Source/Manifest/ValidatorTest.php index 02294d7..ada913d 100644 --- a/tests/Source/SourceCatalogWarmerTest.php +++ b/tests/Source/Manifest/ValidatorTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Tests\Source; +namespace App\Tests\Source\Manifest; -use App\Source\SourceCatalog; -use App\Source\SourceCatalogWarmer; +use App\Source\Manifest\Catalog; +use App\Source\Manifest\Validator; use PHPUnit\Framework\TestCase; -class SourceCatalogWarmerTest extends TestCase +class ValidatorTest extends TestCase { /** @var list */ private array $written = []; @@ -25,28 +25,30 @@ protected function tearDown(): void } /** - * An optional warmer can be skipped, and a manifest that is only read when - * an import selects it is exactly what warming exists to avoid. + * A check that can be skipped is not a check. */ public function testItIsNotOptional(): void { - $this->assertFalse($this->warmer(\dirname(__DIR__, 2).'/config/sources.yaml')->isOptional()); + $this->assertFalse($this->validator(\dirname(__DIR__, 3).'/config/sources.yaml')->isOptional()); } - public function testItWarmsTheShippedManifestWithoutPreloadingAnything(): void + /** + * It validates rather than caches, so it leaves nothing behind to preload. + */ + public function testItAcceptsTheShippedManifestAndWritesNothing(): void { - $warmer = $this->warmer(\dirname(__DIR__, 2).'/config/sources.yaml'); + $validator = $this->validator(\dirname(__DIR__, 3).'/config/sources.yaml'); - $this->assertSame([], $warmer->warmUp(sys_get_temp_dir(), sys_get_temp_dir())); + $this->assertSame([], $validator->warmUp(sys_get_temp_dir(), sys_get_temp_dir())); } /** - * The reason for warming at all: an entry no import selects still fails the - * build rather than waiting to be discovered. + * The reason for checking at build time: an entry no import selects still + * fails the build rather than waiting to be discovered. */ public function testItFailsOnAnEntryNoImportWouldReach(): void { - $warmer = $this->warmer($this->manifest(<<<'YAML' + $validator = $this->validator($this->manifest(<<<'YAML' sources: a-source: title: A source @@ -62,12 +64,12 @@ public function testItFailsOnAnEntryNoImportWouldReach(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('The child config "crs" under "sources.unreached-source" must be configured'); - $warmer->warmUp(sys_get_temp_dir(), sys_get_temp_dir()); + $validator->warmUp(sys_get_temp_dir(), sys_get_temp_dir()); } - private function warmer(string $manifest): SourceCatalogWarmer + private function validator(string $manifest): Validator { - return new SourceCatalogWarmer(new SourceCatalog($manifest)); + return new Validator(new Catalog($manifest)); } private function manifest(string $yaml): string diff --git a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php index 4a67b14..6517824 100644 --- a/tests/Source/MtmSpatialMaps/HandicapParkingTest.php +++ b/tests/Source/MtmSpatialMaps/HandicapParkingTest.php @@ -7,9 +7,9 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; use App\Source\DataSourceReader; +use App\Source\Manifest\Catalog; +use App\Source\Manifest\Descriptor; use App\Source\MtmSpatialMaps\HandicapParking; -use App\Source\SourceCatalog; -use App\Source\SourceDescriptor; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; @@ -22,7 +22,7 @@ class HandicapParkingTest extends TestCase { private const string KEY = 'mtm_spatialmaps-handicap-parking'; - private SourceDescriptor $source; + private Descriptor $source; private string $requestedUrl; @@ -31,7 +31,7 @@ class HandicapParkingTest extends TestCase protected function setUp(): void { - $catalog = new SourceCatalog(\dirname(__DIR__, 3).'/config/sources.yaml'); + $catalog = new Catalog(\dirname(__DIR__, 3).'/config/sources.yaml'); $this->source = $catalog->get(self::KEY); $client = new MockHttpClient(function (string $method, string $url): MockResponse { From 78c3538063736dbf0f98042d9224ea9c2cddc5c4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 09:52:41 +0200 Subject: [PATCH 49/59] Cleaned up comments --- src/Source/Manifest/Catalog.php | 9 --------- src/Source/Manifest/Descriptor.php | 3 --- src/Source/Manifest/Schema.php | 8 -------- 3 files changed, 20 deletions(-) diff --git a/src/Source/Manifest/Catalog.php b/src/Source/Manifest/Catalog.php index 41baa06..722e589 100644 --- a/src/Source/Manifest/Catalog.php +++ b/src/Source/Manifest/Catalog.php @@ -13,11 +13,6 @@ /** * The manifest of data sets this application publishes. * - * The record's shape is declared in Schema rather than checked by hand. Parsing - * is deferred to first use, and Validator reads every record when the - * application is built, so a malformed entry fails the build instead of waiting - * for the one import that happens to select it. - * * @see config/sources.yaml * @see Schema * @see Validator @@ -101,10 +96,6 @@ private function validated(): array throw new \RuntimeException(\sprintf('Source manifest "%s" is not valid YAML: %s', $this->manifest, $exception->getMessage()), previous: $exception); } - // The tree is rooted at the entries themselves, so it never sees the - // key holding them. Without this the wrong shape yields an empty - // catalogue, which reads as "no data sets are registered" rather than - // as a broken file. $sources = \is_array($parsed) ? $parsed['sources'] ?? null : null; if (!\is_array($sources)) { throw new \RuntimeException(\sprintf('Source manifest "%s" must contain a "sources" mapping at the top level.', $this->manifest)); diff --git a/src/Source/Manifest/Descriptor.php b/src/Source/Manifest/Descriptor.php index 34a1e2b..13c66ad 100644 --- a/src/Source/Manifest/Descriptor.php +++ b/src/Source/Manifest/Descriptor.php @@ -5,9 +5,6 @@ namespace App\Source\Manifest; /** - * One entry from the source manifest: what a data set is, where it is read - * from, and on what terms. - * * Everything about a feed except its field mapping, which belongs to the * source class. The names follow DCAT-AP so that publishing the catalogue is a * rename rather than a second survey. diff --git a/src/Source/Manifest/Schema.php b/src/Source/Manifest/Schema.php index 6d20aa3..c4a977e 100644 --- a/src/Source/Manifest/Schema.php +++ b/src/Source/Manifest/Schema.php @@ -10,14 +10,6 @@ /** * The shape of the source manifest, as a Symfony config tree. * - * The tree is rooted at the entries rather than at the file, so a validation - * error names the path a maintainer sees in the manifest. Each `info()` is - * suffixed to that error as a hint, which is the moment a field's rationale is - * needed. - * - * Deliberately not a ConfigurationInterface: that is a bundle extension's - * contract, and this schema describes a committed data file instead. - * * @see config/sources.yaml * @see docs/adr/007-source-manifest.md */ From 05fa2386746f81b2887fe2910e05887c241f5d46 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 10:00:32 +0200 Subject: [PATCH 50/59] Minor correction in comment --- src/Source/Manifest/Descriptor.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Source/Manifest/Descriptor.php b/src/Source/Manifest/Descriptor.php index 13c66ad..9f0e36b 100644 --- a/src/Source/Manifest/Descriptor.php +++ b/src/Source/Manifest/Descriptor.php @@ -6,8 +6,7 @@ /** * Everything about a feed except its field mapping, which belongs to the - * source class. The names follow DCAT-AP so that publishing the catalogue is a - * rename rather than a second survey. + * source class. The names follow DCAT-AP. * * @see config/sources.yaml * @see docs/adr/007-source-manifest.md From 603147dc81fa15be689805b3af22798271889808 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 11:23:18 +0200 Subject: [PATCH 51/59] Extracted the import logic into a DataSourceImporter service --- src/Import/DataSourceImporter.php | 132 ++++++++++++ src/Import/Exception/EmptySourceException.php | 20 ++ .../Exception/UnknownSourceException.php | 28 +++ .../Exception/UpsertFailedException.php | 19 ++ src/Import/ImportResult.php | 23 ++ tests/Import/DataSourceImporterTest.php | 201 ++++++++++++++++++ tests/Source/FakeSource.php | 61 ++++++ 7 files changed, 484 insertions(+) create mode 100644 src/Import/DataSourceImporter.php create mode 100644 src/Import/Exception/EmptySourceException.php create mode 100644 src/Import/Exception/UnknownSourceException.php create mode 100644 src/Import/Exception/UpsertFailedException.php create mode 100644 src/Import/ImportResult.php create mode 100644 tests/Import/DataSourceImporterTest.php create mode 100644 tests/Source/FakeSource.php diff --git a/src/Import/DataSourceImporter.php b/src/Import/DataSourceImporter.php new file mode 100644 index 0000000..6ae8f8c --- /dev/null +++ b/src/Import/DataSourceImporter.php @@ -0,0 +1,132 @@ + $sources + */ + public function __construct( + #[AutowireIterator('app.source')] + private iterable $sources, + private NgsiLdBroker $broker, + #[Autowire(env: 'ENTER_NGSI_CONTEXT_URLS')] + private string $contextUrls, + ) { + } + + /** + * @return list every registered source key, in registration order + */ + public function keys(): array + { + return array_keys($this->registry()); + } + + /** + * Converts a source to NGSI-LD without sending anything. + * + * @return non-empty-list> + * + * @throws UnknownSourceException when no source is registered under the key + * @throws EmptySourceException when the source yields no entities + */ + public function payload(string $key, ?int $limit = null): array + { + $registry = $this->registry(); + + if (!isset($registry[$key])) { + throw new UnknownSourceException($key, array_keys($registry)); + } + + // A limit below 1 is a mistyped option rather than a request to import + // nothing, and importing nothing is the one outcome this class refuses + // to report as a success. + $limit = null === $limit ? null : max(1, $limit); + $contexts = $this->contexts(); + + $payload = []; + foreach ($registry[$key]->entities() as $entity) { + $payload[] = $entity->toArray($contexts); + + // A source reads a whole feed lazily, so the limit stops the + // conversion instead of trimming its result. + if (null !== $limit && \count($payload) >= $limit) { + break; + } + } + + // A source that yields nothing is almost always misconfigured rather + // than genuinely empty, and it fails silently by construction: a + // record skipped for a missing field looks exactly like a feed with no + // records. Fail loudly so it cannot be mistaken for a successful run. + if ([] === $payload) { + throw new EmptySourceException($key); + } + + return $payload; + } + + /** + * @throws UnknownSourceException when no source is registered under the key + * @throws EmptySourceException when the source yields no entities + * @throws UpsertFailedException when the broker cannot be written to + */ + public function import(string $key, ?int $limit = null): ImportResult + { + $payload = $this->payload($key, $limit); + + try { + $status = $this->broker->upsert($payload); + } catch (\Throwable $exception) { + // A broker that is down or rejects the batch is an operational + // condition, not a bug in the conversion. Giving it a type of its + // own lets a caller report it plainly while the exceptions a + // broken feed raises — the ones worth a stack trace — pass through. + throw new UpsertFailedException($exception); + } + + return new ImportResult(\count($payload), $status, $this->broker->brokerUrl()); + } + + /** + * @return array keyed by source key + */ + private function registry(): array + { + $registry = []; + + foreach ($this->sources as $source) { + $registry[$source->key()] = $source; + } + + return $registry; + } + + /** + * @return list + */ + private function contexts(): array + { + return array_values(array_filter(array_map(trim(...), explode(',', $this->contextUrls)))); + } +} diff --git a/src/Import/Exception/EmptySourceException.php b/src/Import/Exception/EmptySourceException.php new file mode 100644 index 0000000..5a87617 --- /dev/null +++ b/src/Import/Exception/EmptySourceException.php @@ -0,0 +1,20 @@ + $known every key that is registered + */ + public function __construct( + public readonly string $key, + public readonly array $known, + ) { + parent::__construct(\sprintf( + 'Unknown source "%s". Available: %s.', + $key, + [] === $known ? 'none' : implode(', ', $known) + )); + } +} diff --git a/src/Import/Exception/UpsertFailedException.php b/src/Import/Exception/UpsertFailedException.php new file mode 100644 index 0000000..1e18c24 --- /dev/null +++ b/src/Import/Exception/UpsertFailedException.php @@ -0,0 +1,19 @@ +getMessage(), previous: $previous); + } +} diff --git a/src/Import/ImportResult.php b/src/Import/ImportResult.php new file mode 100644 index 0000000..f891f65 --- /dev/null +++ b/src/Import/ImportResult.php @@ -0,0 +1,23 @@ + $sources + */ + private function importer( + iterable $sources, + ?MockHttpClient $client = null, + string $contextUrls = self::CONTEXT_URLS, + ): DataSourceImporter { + return new DataSourceImporter( + $sources, + new NgsiLdBroker($client ?? new MockHttpClient(), self::BROKER_URL), + $contextUrls, + ); + } + + public function testItListsTheRegisteredSourceKeys(): void + { + $importer = $this->importer([FakeSource::withEntities('a-source'), FakeSource::withEntities('b-source')]); + + $this->assertSame(['a-source', 'b-source'], $importer->keys()); + } + + public function testItListsNothingWhenNoSourceIsRegistered(): void + { + $this->assertSame([], $this->importer([])->keys()); + } + + public function testItRejectsAnUnknownSourceAndNamesTheKnownOnes(): void + { + $importer = $this->importer([FakeSource::withEntities('some-source')]); + + $this->expectException(UnknownSourceException::class); + $this->expectExceptionMessage('Unknown source "nope". Available: some-source.'); + + $importer->payload('nope'); + } + + /** + * The important one: a source yielding nothing used to be reported as a + * completed import, which is indistinguishable from a working one. + */ + public function testItFailsWhenASourceProducesNothing(): void + { + $importer = $this->importer([new FakeSource('empty-source')]); + + $this->expectException(EmptySourceException::class); + $this->expectExceptionMessage('Source "empty-source" produced no entities.'); + + $importer->payload('empty-source'); + } + + /** + * The guard belongs to the conversion rather than to the dry run, so an + * import that intends to send is held to it too. + */ + public function testItSendsNothingWhenASourceProducesNothing(): void + { + $client = new MockHttpClient(); + + try { + $this->importer([new FakeSource('empty-source')], $client)->import('empty-source'); + $this->fail('An empty source was reported as a completed import.'); + } catch (EmptySourceException) { + $this->assertSame(0, $client->getRequestsCount(), 'An empty payload was sent to the broker.'); + } + } + + public function testEveryEntityCarriesTheConfiguredContexts(): void + { + $importer = $this->importer([FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')]); + + $payload = $importer->payload('one-entity'); + + $this->assertSame( + ['https://example.com/domain.jsonld', 'https://example.com/core.jsonld'], + $payload[0]['@context'] + ); + } + + /** + * The contexts arrive as one comma-separated environment variable, so they + * are written by hand and carry whatever spacing that produces. + */ + public function testItIgnoresSpacingAndEmptyEntriesInTheConfiguredContexts(): void + { + $importer = $this->importer( + [FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')], + contextUrls: ' https://example.com/domain.jsonld , ,', + ); + + $payload = $importer->payload('one-entity'); + + $this->assertSame(['https://example.com/domain.jsonld'], $payload[0]['@context']); + } + + public function testALimitCapsThePayload(): void + { + $importer = $this->importer([FakeSource::withEntities('many', 'urn:1', 'urn:2', 'urn:3', 'urn:4', 'urn:5')]); + + $this->assertCount(2, $importer->payload('many', 2)); + } + + /** + * A real source reads a whole feed, so a limit that converts everything + * and then trims would do all the work it was given to avoid. + */ + public function testALimitStopsPullingFromTheSource(): void + { + $source = FakeSource::withEntities('many', 'urn:1', 'urn:2', 'urn:3', 'urn:4', 'urn:5'); + + $this->importer([$source])->payload('many', 2); + + $this->assertSame(2, $source->produced()); + } + + /** + * --limit 0 is a mistyped option. Honouring it would produce an empty + * payload, which is the one outcome an import refuses to call a success. + */ + public function testALimitBelowOneStillImportsOneEntity(): void + { + $importer = $this->importer([FakeSource::withEntities('many', 'urn:1', 'urn:2')]); + + $this->assertCount(1, $importer->payload('many', 0)); + } + + public function testBuildingThePayloadSendsNothing(): void + { + $client = new MockHttpClient(); + + $this->importer([FakeSource::withEntities('one-entity', 'urn:1')], $client)->payload('one-entity'); + + $this->assertSame(0, $client->getRequestsCount()); + } + + public function testItUpsertsThePayloadAndReportsWhatTheBrokerDid(): void + { + $client = new MockHttpClient(new MockResponse('', ['http_code' => 204])); + + $result = $this->importer([FakeSource::withEntities('many', 'urn:1', 'urn:2')], $client)->import('many'); + + $this->assertSame(2, $result->count); + $this->assertSame(204, $result->status); + $this->assertSame(self::BROKER_URL, $result->brokerUrl); + $this->assertSame(1, $client->getRequestsCount(), 'The entities were not sent as one batch.'); + } + + public function testItReportsWhatTheBrokerSaidWhenTheUpsertIsRejected(): void + { + $client = new MockHttpClient(new MockResponse('{"title":"Bad Request"}', ['http_code' => 400])); + + try { + $this->importer([FakeSource::withEntities('one-entity', 'urn:1')], $client)->import('one-entity'); + $this->fail('A rejected upsert was reported as a completed import.'); + } catch (UpsertFailedException $exception) { + $this->assertStringContainsString('HTTP 400', $exception->getMessage()); + $this->assertStringContainsString('Bad Request', $exception->getMessage()); + $this->assertNotNull($exception->getPrevious(), 'The broker\'s own exception was discarded.'); + } + } + + /** + * A broker that cannot be reached fails in the HTTP client rather than in + * the broker's status check, and the two are the same thing to a caller. + */ + public function testItFailsTheSameWayWhenTheBrokerCannotBeReached(): void + { + $client = new MockHttpClient(static function (): never { + throw new TransportException('Connection refused'); + }); + + $this->expectException(UpsertFailedException::class); + $this->expectExceptionMessage('Connection refused'); + + $this->importer([FakeSource::withEntities('one-entity', 'urn:1')], $client)->import('one-entity'); + } +} diff --git a/tests/Source/FakeSource.php b/tests/Source/FakeSource.php new file mode 100644 index 0000000..052ab33 --- /dev/null +++ b/tests/Source/FakeSource.php @@ -0,0 +1,61 @@ + $entities + */ + public function __construct( + private readonly string $key, + private readonly array $entities = [], + ) { + } + + /** + * @param string ...$ids entity ids, one entity each + */ + public static function withEntities(string $key, string ...$ids): self + { + return new self($key, array_map( + static fn (string $id): NgsiEntity => new NgsiEntity($id, 'Example'), + array_values($ids) + )); + } + + public function key(): string + { + return $this->key; + } + + public function entities(): iterable + { + foreach ($this->entities as $entity) { + ++$this->produced; + + yield $entity; + } + } + + /** + * How many entities were actually pulled from this source. + */ + public function produced(): int + { + return $this->produced; + } +} From 07aec4f9ddb4db0c3752e9249059bda65857dae0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 11:24:05 +0200 Subject: [PATCH 52/59] Reduced ImportCommand to console plumbing --- src/Command/ImportCommand.php | 97 ++++++++------------ src/Source/SourceInterface.php | 4 +- tests/Command/ImportCommandTest.php | 131 +++++++++++++--------------- 3 files changed, 97 insertions(+), 135 deletions(-) diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php index b0c6a46..e1c4fa9 100644 --- a/src/Command/ImportCommand.php +++ b/src/Command/ImportCommand.php @@ -4,8 +4,10 @@ namespace App\Command; -use App\Broker\NgsiLdBroker; -use App\Source\SourceInterface; +use App\Import\DataSourceImporter; +use App\Import\Exception\EmptySourceException; +use App\Import\Exception\UnknownSourceException; +use App\Import\Exception\UpsertFailedException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -13,9 +15,11 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; +/** + * The console front for DataSourceImporter: selects a source, and turns what + * the import raises into an exit code and something readable. + */ #[AsCommand( name: 'app:import', description: 'Convert a data source to NGSI-LD and upsert it into the context broker.', @@ -24,15 +28,8 @@ final class ImportCommand extends Command { private const JSON_FLAGS = \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE; - /** - * @param iterable $sources - */ public function __construct( - #[AutowireIterator('app.source')] - private readonly iterable $sources, - private readonly NgsiLdBroker $broker, - #[Autowire(env: 'ENTER_NGSI_CONTEXT_URLS')] - private readonly string $contextUrls, + private readonly DataSourceImporter $importer, ) { parent::__construct(); } @@ -49,12 +46,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $sources = []; - foreach ($this->sources as $source) { - $sources[$source->key()] = $source; - } + $keys = $this->importer->keys(); - if ([] === $sources) { + if ([] === $keys) { $io->error('No data sources are registered.'); $io->listing([ 'A source must implement App\Source\SourceInterface.', @@ -64,50 +58,46 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $key = $input->getArgument('source'); + $argument = $input->getArgument('source'); - if (null === $key) { + if (null === $argument) { // Options only make sense together with a source. Listing the // sources and exiting successfully would look like an import ran. if ($input->getOption('dry-run') || null !== $input->getOption('limit')) { $io->error(\sprintf( 'No source given. Available: %s.', - implode(', ', array_keys($sources)) + implode(', ', $keys) )); return Command::INVALID; } $io->section('Available sources'); - $io->listing(array_keys($sources)); + $io->listing($keys); return Command::SUCCESS; } - if (!isset($sources[$key])) { - $io->error(\sprintf('Unknown source "%s". Available: %s.', $key, implode(', ', array_keys($sources)))); + $key = (string) $argument; + $limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null; - return Command::INVALID; - } - - $limit = null !== $input->getOption('limit') ? max(1, (int) $input->getOption('limit')) : null; - $contexts = $this->contexts(); + try { + if ($input->getOption('dry-run')) { + $payload = $this->importer->payload($key, $limit); - $payload = []; - foreach ($sources[$key]->entities() as $entity) { - $payload[] = $entity->toArray($contexts); + $output->writeln(json_encode($payload, self::JSON_FLAGS)); + $io->note(\sprintf('Dry run: %d entities were not sent.', \count($payload))); - if (null !== $limit && \count($payload) >= $limit) { - break; + return Command::SUCCESS; } - } - // A source that yields nothing is almost always misconfigured rather - // than genuinely empty, and it fails silently by construction: a - // record skipped for a missing field looks exactly like a feed with no - // records. Fail loudly so it cannot be mistaken for a successful run. - if ([] === $payload) { - $io->error(\sprintf('Source "%s" produced no entities.', $key)); + $result = $this->importer->import($key, $limit); + } catch (UnknownSourceException $exception) { + $io->error($exception->getMessage()); + + return Command::INVALID; + } catch (EmptySourceException $exception) { + $io->error($exception->getMessage()); $io->text( 'The source ran to completion without raising an exception, so every record was ' .'discarded by the source\'s own guards rather than failing. Verbosity flags will ' @@ -120,18 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]); return Command::FAILURE; - } - - if ($input->getOption('dry-run')) { - $output->writeln(json_encode($payload, self::JSON_FLAGS)); - $io->note(\sprintf('Dry run: %d entities were not sent.', \count($payload))); - - return Command::SUCCESS; - } - - try { - $status = $this->broker->upsert($payload); - } catch (\Throwable $exception) { + } catch (UpsertFailedException $exception) { $io->error($exception->getMessage()); return Command::FAILURE; @@ -139,19 +118,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success(\sprintf( 'Upserted %d entities into %s (HTTP %d).', - \count($payload), - $this->broker->brokerUrl(), - $status + $result->count, + $result->brokerUrl, + $result->status )); return Command::SUCCESS; } - - /** - * @return list - */ - private function contexts(): array - { - return array_values(array_filter(array_map(trim(...), explode(',', $this->contextUrls)))); - } } diff --git a/src/Source/SourceInterface.php b/src/Source/SourceInterface.php index b372d28..eaea6a7 100644 --- a/src/Source/SourceInterface.php +++ b/src/Source/SourceInterface.php @@ -12,8 +12,8 @@ * * Implementations own everything specific to their feed: where it comes from, * its field names, its quirks, and which Smart Data Model it maps onto. The - * broker and the import command stay unaware of all of it, so adding the next - * ENTER data set means adding one class and nothing else. + * broker and the import stay unaware of all of it, so adding the next ENTER + * data set means adding one class and nothing else. */ #[AutoconfigureTag('app.source')] interface SourceInterface diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php index a1d68ad..c337ed1 100644 --- a/tests/Command/ImportCommandTest.php +++ b/tests/Command/ImportCommandTest.php @@ -6,72 +6,42 @@ use App\Broker\NgsiLdBroker; use App\Command\ImportCommand; -use App\Ngsi\NgsiEntity; +use App\Import\DataSourceImporter; use App\Source\SourceInterface; +use App\Tests\Source\FakeSource; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +/** + * The console surface only: the affordances around an import, and which exit + * code each outcome maps to. What an import decides belongs to + * DataSourceImporter and is covered by DataSourceImporterTest. + */ class ImportCommandTest extends TestCase { /** * @param iterable $sources */ - private function tester(iterable $sources): CommandTester + private function tester(iterable $sources, ?MockHttpClient $client = null): CommandTester { - return new CommandTester(new ImportCommand( + return new CommandTester(new ImportCommand(new DataSourceImporter( $sources, - new NgsiLdBroker(new MockHttpClient(), 'http://broker.invalid'), + new NgsiLdBroker($client ?? new MockHttpClient(), 'http://broker.invalid'), 'https://example.com/context.jsonld', - )); + ))); } - private function source(string $key, NgsiEntity ...$entities): SourceInterface + public function testItListsTheSourcesWhenCalledBare(): void { - return new readonly class($key, $entities) implements SourceInterface { - /** @param list $entities */ - public function __construct( - private string $key, - private array $entities, - ) { - } - - public function key(): string - { - return $this->key; - } - - public function entities(): iterable - { - yield from $this->entities; - } - }; - } - - /** - * The important one: a source yielding nothing used to exit successfully - * with a warning, which is indistinguishable from a working import. - */ - public function testItFailsWhenASourceProducesNothing(): void - { - $tester = $this->tester([$this->source('empty-source')]); - - $status = $tester->execute(['source' => 'empty-source', '--dry-run' => true]); - - $this->assertSame(Command::FAILURE, $status); - $this->assertStringContainsString('produced no entities', $tester->getDisplay()); - } - - public function testItSuggestsCausesWhenASourceProducesNothing(): void - { - $tester = $this->tester([$this->source('empty-source')]); - $tester->execute(['source' => 'empty-source', '--dry-run' => true]); + $tester = $this->tester([FakeSource::withEntities('some-source')]); - $display = $tester->getDisplay(); + $status = $tester->execute([]); - $this->assertStringContainsString('path or URL', $display); - $this->assertStringContainsString('envelope, nesting, field names', $display); + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('some-source', $tester->getDisplay()); } public function testItFailsWhenNoSourcesAreRegistered(): void @@ -90,7 +60,7 @@ public function testItFailsWhenNoSourcesAreRegistered(): void */ public function testItRejectsOptionsWithoutASource(): void { - $tester = $this->tester([$this->source('some-source')]); + $tester = $this->tester([FakeSource::withEntities('some-source')]); $status = $tester->execute(['--dry-run' => true]); @@ -98,32 +68,37 @@ public function testItRejectsOptionsWithoutASource(): void $this->assertStringContainsString('No source given', $tester->getDisplay()); } - public function testItStillListsSourcesWhenCalledBare(): void + public function testAnUnknownSourceIsTheCallersMistake(): void { - $tester = $this->tester([$this->source('some-source')]); + $tester = $this->tester([FakeSource::withEntities('some-source')]); - $status = $tester->execute([]); + $status = $tester->execute(['source' => 'nope']); - $this->assertSame(Command::SUCCESS, $status); - $this->assertStringContainsString('some-source', $tester->getDisplay()); + $this->assertSame(Command::INVALID, $status); + $this->assertStringContainsString('Unknown source "nope"', $tester->getDisplay()); } - public function testItRejectsAnUnknownSource(): void + /** + * The failure carries no exception to show, so the command has to supply + * the places worth looking itself. + */ + public function testAnEmptySourceFailsAndSuggestsCauses(): void { - $tester = $this->tester([$this->source('some-source')]); + $tester = $this->tester([new FakeSource('empty-source')]); - $status = $tester->execute(['source' => 'nope']); + $status = $tester->execute(['source' => 'empty-source']); + $display = $tester->getDisplay(); - $this->assertSame(Command::INVALID, $status); - $this->assertStringContainsString('Unknown source "nope"', $tester->getDisplay()); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('produced no entities', $display); + $this->assertStringContainsString('path or URL', $display); + $this->assertStringContainsString('envelope, nesting, field names', $display); } public function testDryRunPrintsThePayloadAndSendsNothing(): void { - $entity = new NgsiEntity('urn:ngsi-ld:Example:1', 'Example') - ->setProperty('name', 'Example'); - - $tester = $this->tester([$this->source('one-entity', $entity)]); + $client = new MockHttpClient(); + $tester = $this->tester([FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')], $client); $status = $tester->execute(['source' => 'one-entity', '--dry-run' => true]); $display = $tester->getDisplay(); @@ -131,18 +106,34 @@ public function testDryRunPrintsThePayloadAndSendsNothing(): void $this->assertSame(Command::SUCCESS, $status); $this->assertStringContainsString('"urn:ngsi-ld:Example:1"', $display); $this->assertStringContainsString('1 entities were not sent', $display); + $this->assertSame(0, $client->getRequestsCount()); } - public function testLimitCapsThePayload(): void + public function testItReportsWhatWasUpserted(): void { - $entities = []; - foreach (range(1, 5) as $i) { - $entities[] = new NgsiEntity(\sprintf('urn:ngsi-ld:Example:%d', $i), 'Example'); - } + $client = new MockHttpClient(new MockResponse('', ['http_code' => 204])); + $tester = $this->tester([FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')], $client); - $tester = $this->tester([$this->source('many', ...$entities)]); - $tester->execute(['source' => 'many', '--dry-run' => true, '--limit' => 2]); + $status = $tester->execute(['source' => 'one-entity']); + $display = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('Upserted 1 entities', $display); + $this->assertStringContainsString('HTTP 204', $display); + } + + /** + * The broker being down is an operational condition rather than a bug, so + * it is reported as a message instead of an uncaught exception. + */ + public function testABrokerFailureIsReportedAsAnError(): void + { + $client = new MockHttpClient(new MockResponse('', ['http_code' => 500])); + $tester = $this->tester([FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')], $client); - $this->assertStringContainsString('2 entities were not sent', $tester->getDisplay()); + $status = $tester->execute(['source' => 'one-entity']); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('HTTP 500', $tester->getDisplay()); } } From 64aa10f06153385d43d39f205c3b7bbb811616c8 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 12:57:54 +0200 Subject: [PATCH 53/59] Move context from comma seperated list in .env to each dataset via config/sources.yaml --- .env | 2 +- config/sources.yaml | 1 + src/Import/DataSourceImporter.php | 47 +++++++-------- src/Source/Manifest/Catalog.php | 5 +- src/Source/Manifest/Descriptor.php | 1 + src/Source/Manifest/Schema.php | 5 ++ tests/Command/ImportCommandTest.php | 12 +++- tests/Import/DataSourceImporterTest.php | 72 ++++++++++++++++++++--- tests/Source/Manifest/CatalogTest.php | 7 +++ tests/Source/Manifest/ValidatorTest.php | 2 + tests/Source/Manifest/WritesManifests.php | 61 +++++++++++++++++++ 11 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 tests/Source/Manifest/WritesManifests.php diff --git a/.env b/.env index a9d7b27..b68e4c9 100644 --- a/.env +++ b/.env @@ -36,7 +36,7 @@ APP_BROKER_BASE_URI=http://scorpio.local:9090/ # JSON-LD contexts attached to every entity, outermost last so the ETSI core # context resolves the NGSI-LD terms and the domain context resolves the # Smart Data Models ones. -ENTER_NGSI_CONTEXT_URLS='https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld,https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld' +ENTER_NGSI_CONTEXT_URLS='https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld' # A single domain context, for the Link header that read requests need. Only # used by `task broker:entities`, and it must be the context defining the type diff --git a/config/sources.yaml b/config/sources.yaml index b011401..bebb760 100644 --- a/config/sources.yaml +++ b/config/sources.yaml @@ -17,6 +17,7 @@ sources: media_type: application/geo+json crs: 'EPSG:25832' model: OnStreetParking + context_url: 'https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld' update_frequency: continuous # The portal states no licence for this data set. DCAT-AP requires diff --git a/src/Import/DataSourceImporter.php b/src/Import/DataSourceImporter.php index 6ae8f8c..3030870 100644 --- a/src/Import/DataSourceImporter.php +++ b/src/Import/DataSourceImporter.php @@ -8,17 +8,13 @@ use App\Import\Exception\EmptySourceException; use App\Import\Exception\UnknownSourceException; use App\Import\Exception\UpsertFailedException; +use App\Source\Manifest\Catalog; use App\Source\SourceInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; /** * Converts a registered source to NGSI-LD and upserts it into the broker. - * - * Owns what an import decides: which sources exist, which contexts their - * entities carry, how many to take, and what counts as a failed run. That - * leaves the command around it with argument parsing and exit codes, and lets - * the decisions be exercised without a console. */ final readonly class DataSourceImporter { @@ -28,6 +24,7 @@ public function __construct( #[AutowireIterator('app.source')] private iterable $sources, + private Catalog $catalog, private NgsiLdBroker $broker, #[Autowire(env: 'ENTER_NGSI_CONTEXT_URLS')] private string $contextUrls, @@ -43,7 +40,7 @@ public function keys(): array } /** - * Converts a source to NGSI-LD without sending anything. + * Converts a source to NGSI-LD. * * @return non-empty-list> * @@ -52,33 +49,30 @@ public function keys(): array */ public function payload(string $key, ?int $limit = null): array { + // Collect all dataset keys. $registry = $this->registry(); + // Check if requested dataset key exists. if (!isset($registry[$key])) { throw new UnknownSourceException($key, array_keys($registry)); } - // A limit below 1 is a mistyped option rather than a request to import - // nothing, and importing nothing is the one outcome this class refuses - // to report as a success. + // Define minimum limit, in case of limit defined as less than 1. $limit = null === $limit ? null : max(1, $limit); - $contexts = $this->contexts(); + + // Load context for given dataset. + $contexts = $this->contexts($key); $payload = []; foreach ($registry[$key]->entities() as $entity) { $payload[] = $entity->toArray($contexts); - // A source reads a whole feed lazily, so the limit stops the - // conversion instead of trimming its result. + // Break upon limit. if (null !== $limit && \count($payload) >= $limit) { break; } } - // A source that yields nothing is almost always misconfigured rather - // than genuinely empty, and it fails silently by construction: a - // record skipped for a missing field looks exactly like a feed with no - // records. Fail loudly so it cannot be mistaken for a successful run. if ([] === $payload) { throw new EmptySourceException($key); } @@ -93,22 +87,24 @@ public function payload(string $key, ?int $limit = null): array */ public function import(string $key, ?int $limit = null): ImportResult { + // Get payload from dataset. $payload = $this->payload($key, $limit); + // Try to upsert broker with payload. try { $status = $this->broker->upsert($payload); } catch (\Throwable $exception) { - // A broker that is down or rejects the batch is an operational - // condition, not a bug in the conversion. Giving it a type of its - // own lets a caller report it plainly while the exceptions a - // broken feed raises — the ones worth a stack trace — pass through. throw new UpsertFailedException($exception); } + // Return result. return new ImportResult(\count($payload), $status, $this->broker->brokerUrl()); } /** + * Get list of registered datasets. + * + * @see config/sources.yaml * @return array keyed by source key */ private function registry(): array @@ -123,10 +119,15 @@ private function registry(): array } /** - * @return list + * Return an array of contexts. Each dataset holds its own context + * @see config/sources.yaml + * @return array */ - private function contexts(): array + private function contexts(string $key): array { - return array_values(array_filter(array_map(trim(...), explode(',', $this->contextUrls)))); + return [ + $this->catalog->get($key)->contextUrl, + ...array_values(array_filter(array_map(trim(...), explode(',', $this->contextUrls)))), + ]; } } diff --git a/src/Source/Manifest/Catalog.php b/src/Source/Manifest/Catalog.php index 722e589..71ad47f 100644 --- a/src/Source/Manifest/Catalog.php +++ b/src/Source/Manifest/Catalog.php @@ -67,6 +67,7 @@ private function load(): array accessUrl: $entry['access_url'], crs: $entry['crs'], model: $entry['model'], + contextUrl: $entry['context_url'], description: $entry['description'], publisher: $entry['publisher'], contact: $entry['contact'], @@ -82,7 +83,7 @@ private function load(): array } /** - * @return array}> + * @return array}> */ private function validated(): array { @@ -102,7 +103,7 @@ private function validated(): array } try { - /** @var array}> $processed */ + /** @var array}> $processed */ $processed = new Processor()->process(Schema::tree(), [$sources]); } catch (InvalidConfigurationException $exception) { throw new \RuntimeException(\sprintf('Source manifest "%s" is invalid: %s', $this->manifest, $exception->getMessage()), previous: $exception); diff --git a/src/Source/Manifest/Descriptor.php b/src/Source/Manifest/Descriptor.php index 9f0e36b..46d0631 100644 --- a/src/Source/Manifest/Descriptor.php +++ b/src/Source/Manifest/Descriptor.php @@ -26,6 +26,7 @@ public function __construct( public string $accessUrl, public string $crs, public string $model, + public string $contextUrl, public ?string $description = null, public ?string $publisher = null, public ?string $contact = null, diff --git a/src/Source/Manifest/Schema.php b/src/Source/Manifest/Schema.php index c4a977e..bbaf41c 100644 --- a/src/Source/Manifest/Schema.php +++ b/src/Source/Manifest/Schema.php @@ -50,6 +50,11 @@ public static function tree(): NodeInterface ->cannotBeEmpty() ->info('Smart Data Model the data set is published as.') ->end() + ->scalarNode('context_url') + ->isRequired() + ->cannotBeEmpty() + ->info('JSON-LD context defining the model\'s terms. Without the right one a consumer reads the entity\'s attributes as undefined strings.') + ->end() ->scalarNode('description')->defaultNull()->end() ->scalarNode('publisher')->defaultNull()->end() ->scalarNode('contact')->defaultNull()->end() diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php index c337ed1..6d4c7ca 100644 --- a/tests/Command/ImportCommandTest.php +++ b/tests/Command/ImportCommandTest.php @@ -7,8 +7,10 @@ use App\Broker\NgsiLdBroker; use App\Command\ImportCommand; use App\Import\DataSourceImporter; +use App\Source\Manifest\Catalog; use App\Source\SourceInterface; use App\Tests\Source\FakeSource; +use App\Tests\Source\Manifest\WritesManifests; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -22,15 +24,23 @@ */ class ImportCommandTest extends TestCase { + use WritesManifests; + /** * @param iterable $sources */ private function tester(iterable $sources, ?MockHttpClient $client = null): CommandTester { + $keys = []; + foreach ($sources as $source) { + $keys[] = $source->key(); + } + return new CommandTester(new ImportCommand(new DataSourceImporter( $sources, + new Catalog($this->manifestFor($keys)), new NgsiLdBroker($client ?? new MockHttpClient(), 'http://broker.invalid'), - 'https://example.com/context.jsonld', + 'https://example.com/core.jsonld', ))); } diff --git a/tests/Import/DataSourceImporterTest.php b/tests/Import/DataSourceImporterTest.php index a1c666a..3e838a7 100644 --- a/tests/Import/DataSourceImporterTest.php +++ b/tests/Import/DataSourceImporterTest.php @@ -9,8 +9,10 @@ use App\Import\Exception\EmptySourceException; use App\Import\Exception\UnknownSourceException; use App\Import\Exception\UpsertFailedException; +use App\Source\Manifest\Catalog; use App\Source\SourceInterface; use App\Tests\Source\FakeSource; +use App\Tests\Source\Manifest\WritesManifests; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\MockHttpClient; @@ -18,20 +20,27 @@ class DataSourceImporterTest extends TestCase { + use WritesManifests; private const string BROKER_URL = 'http://broker.invalid'; - private const string CONTEXT_URLS = 'https://example.com/domain.jsonld,https://example.com/core.jsonld'; + private const string CORE_CONTEXT = 'https://example.com/core.jsonld'; /** - * @param iterable $sources + * @param iterable $sources each is registered in the manifest under its own key */ private function importer( iterable $sources, ?MockHttpClient $client = null, - string $contextUrls = self::CONTEXT_URLS, + string $contextUrls = self::CORE_CONTEXT, ): DataSourceImporter { + $keys = []; + foreach ($sources as $source) { + $keys[] = $source->key(); + } + return new DataSourceImporter( $sources, + new Catalog($this->manifestFor($keys)), new NgsiLdBroker($client ?? new MockHttpClient(), self::BROKER_URL), $contextUrls, ); @@ -89,18 +98,64 @@ public function testItSendsNothingWhenASourceProducesNothing(): void } } - public function testEveryEntityCarriesTheConfiguredContexts(): void + /** + * Later entries win term conflicts, so the data set's own context comes + * first and the core context last, where it stays authoritative over the + * NGSI-LD terms a domain context may also define. + */ + public function testEveryEntityCarriesItsDataSetContextThenTheCoreContext(): void { $importer = $this->importer([FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')]); $payload = $importer->payload('one-entity'); $this->assertSame( - ['https://example.com/domain.jsonld', 'https://example.com/core.jsonld'], + [self::dataSetContext('one-entity'), self::CORE_CONTEXT], $payload[0]['@context'] ); } + /** + * Two data sets published under different models must not advertise each + * other's vocabulary, which a single app-wide list cannot avoid. + */ + public function testEachDataSetCarriesOnlyItsOwnContext(): void + { + $importer = $this->importer([ + FakeSource::withEntities('first-source', 'urn:ngsi-ld:Example:1'), + FakeSource::withEntities('second-source', 'urn:ngsi-ld:Example:2'), + ]); + + $this->assertSame( + [self::dataSetContext('first-source'), self::CORE_CONTEXT], + $importer->payload('first-source')[0]['@context'] + ); + $this->assertSame( + [self::dataSetContext('second-source'), self::CORE_CONTEXT], + $importer->payload('second-source')[0]['@context'] + ); + } + + /** + * A source registered in the container but not in the manifest has no + * context to publish under, so it fails rather than emitting entities a + * consumer cannot resolve. + */ + public function testItFailsWhenASourceHasNoManifestEntry(): void + { + $importer = new DataSourceImporter( + [FakeSource::withEntities('unregistered', 'urn:ngsi-ld:Example:1')], + new Catalog($this->manifestFor(['other-source'])), + new NgsiLdBroker(new MockHttpClient(), self::BROKER_URL), + self::CORE_CONTEXT, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No entry for source "unregistered"'); + + $importer->payload('unregistered'); + } + /** * The contexts arrive as one comma-separated environment variable, so they * are written by hand and carry whatever spacing that produces. @@ -109,12 +164,15 @@ public function testItIgnoresSpacingAndEmptyEntriesInTheConfiguredContexts(): vo { $importer = $this->importer( [FakeSource::withEntities('one-entity', 'urn:ngsi-ld:Example:1')], - contextUrls: ' https://example.com/domain.jsonld , ,', + contextUrls: ' '.self::CORE_CONTEXT.' , ,', ); $payload = $importer->payload('one-entity'); - $this->assertSame(['https://example.com/domain.jsonld'], $payload[0]['@context']); + $this->assertSame( + [self::dataSetContext('one-entity'), self::CORE_CONTEXT], + $payload[0]['@context'] + ); } public function testALimitCapsThePayload(): void diff --git a/tests/Source/Manifest/CatalogTest.php b/tests/Source/Manifest/CatalogTest.php index bfa83b5..2707d49 100644 --- a/tests/Source/Manifest/CatalogTest.php +++ b/tests/Source/Manifest/CatalogTest.php @@ -43,6 +43,7 @@ public function testEveryShippedEntryCanBeImportedFrom(): void $this->assertMatchesRegularExpression('#^https?://#', $descriptor->accessUrl, $key); $this->assertMatchesRegularExpression('/^EPSG:\d+$/', $descriptor->crs, $key); $this->assertNotSame('', $descriptor->model, $key); + $this->assertMatchesRegularExpression('#^https?://#', $descriptor->contextUrl, $key); } } @@ -55,6 +56,7 @@ public function testItNamesTheKnownEntriesWhenAskedForAnUnknownOne(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld YAML)); $this->expectException(\RuntimeException::class); @@ -81,6 +83,7 @@ public function testItRejectsAnEntryMissingAFieldTheImportNeeds(): void title: A source access_url: https://example.com/feed.json model: Example + context_url: https://example.com/context.jsonld YAML)); $this->expectException(\RuntimeException::class); @@ -98,6 +101,7 @@ public function testItRejectsAnOmittedFieldWithoutAReason(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld omitted_fields: some_field: ~ YAML)); @@ -122,6 +126,7 @@ public function testItKeepsADashedSourceKeyIntact(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld YAML)); $this->assertSame(['handicap-parking'], array_keys($catalog->all())); @@ -141,6 +146,7 @@ public function testItRejectsAFieldTheManifestDoesNotDefine(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld license: CC-BY-4.0 YAML)); @@ -183,6 +189,7 @@ public function testItReadsABlankOptionalFieldAsUnknown(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld publisher: ' Aarhus Kommune ' contact: '' licence: ~ diff --git a/tests/Source/Manifest/ValidatorTest.php b/tests/Source/Manifest/ValidatorTest.php index ada913d..53230bb 100644 --- a/tests/Source/Manifest/ValidatorTest.php +++ b/tests/Source/Manifest/ValidatorTest.php @@ -55,10 +55,12 @@ public function testItFailsOnAnEntryNoImportWouldReach(): void access_url: https://example.com/feed.json crs: EPSG:25832 model: Example + context_url: https://example.com/context.jsonld unreached-source: title: Another source access_url: https://example.com/other.json model: Example + context_url: https://example.com/context.jsonld YAML)); $this->expectException(\RuntimeException::class); diff --git a/tests/Source/Manifest/WritesManifests.php b/tests/Source/Manifest/WritesManifests.php new file mode 100644 index 0000000..121509f --- /dev/null +++ b/tests/Source/Manifest/WritesManifests.php @@ -0,0 +1,61 @@ + */ + private array $manifests = []; + + protected function tearDown(): void + { + foreach ($this->manifests as $path) { + if (is_file($path)) { + unlink($path); + } + } + + $this->manifests = []; + } + + protected static function dataSetContext(string $key): string + { + return \sprintf('https://example.com/%s.jsonld', $key); + } + + /** + * @param list $keys + * + * @return string path to the manifest + */ + protected function manifestFor(array $keys): string + { + $entries = array_map(static fn (string $key): string => \sprintf( + " %s:\n title: %s\n access_url: https://example.com/%s.json\n crs: EPSG:25832\n model: Example\n context_url: %s", + $key, + $key, + $key, + self::dataSetContext($key), + ), $keys); + + $path = tempnam(sys_get_temp_dir(), 'sources-'); + + if (false === $path) { + $this->fail('Could not create a temporary manifest.'); + } + + file_put_contents($path, "sources:\n".implode("\n", $entries)."\n"); + $this->manifests[] = $path; + + return $path; + } +} From 87e540f1914158fbf9913a7005c9b6503827480a Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 13:06:53 +0200 Subject: [PATCH 54/59] Minor corrections --- src/Geo/Wgs84Transformer.php | 13 ++++--------- src/Import/Exception/EmptySourceException.php | 4 ---- src/Import/Exception/UnknownSourceException.php | 3 --- src/Import/Exception/UpsertFailedException.php | 3 --- 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/Geo/Wgs84Transformer.php b/src/Geo/Wgs84Transformer.php index 9f57abe..19489ba 100644 --- a/src/Geo/Wgs84Transformer.php +++ b/src/Geo/Wgs84Transformer.php @@ -20,10 +20,6 @@ final class Wgs84Transformer /** * PROJ definitions for coordinate systems this application reads. * - * The `towgs84=0,0,0,0,0,0,0` term treats ETRS89 as equivalent to WGS84. - * The two datums have diverged by roughly a metre since 1989; see ADR 004 - * for why that is accepted here. - * * @var array */ private const array DEFINITIONS = [ @@ -58,10 +54,9 @@ public function __construct(array $definitions = []) } /** - * Coordinates are not rounded. Downstream use is unknown and may include - * planning work, so the transformed value is published as computed. + * Coordinates are not rounded. * - * @param string $srid source CRS, e.g. "EPSG:25832" + * @param string $srid source CRS * * @return array{float, float} GeoJSON coordinate order: [longitude, latitude] */ @@ -80,7 +75,7 @@ public function toWgs84(string $srid, float $x, float $y): array } /** - * @param string $srid source CRS, e.g. "EPSG:25832" + * @param string $srid source CRS * * @return array{type: string, coordinates: array{float, float}} GeoJSON Point */ @@ -102,7 +97,7 @@ public function point(string $srid, float $x, float $y): array * A third ordinate (elevation) is dropped: the inputs are two-dimensional, * and vertical datums are a separate concern this class does not model. * - * @param string $srid source CRS, e.g. "EPSG:25832" + * @param string $srid source CRS * @param array $geometry GeoJSON geometry object * * @return array{type: string, coordinates: mixed} diff --git a/src/Import/Exception/EmptySourceException.php b/src/Import/Exception/EmptySourceException.php index 5a87617..d6bcc3f 100644 --- a/src/Import/Exception/EmptySourceException.php +++ b/src/Import/Exception/EmptySourceException.php @@ -6,10 +6,6 @@ /** * A source ran to completion and yielded no entities. - * - * Carries no diagnosis: the source discarded every record through its own - * guards without raising anything, so there is nothing here to report beyond - * which source it was. */ final class EmptySourceException extends \RuntimeException { diff --git a/src/Import/Exception/UnknownSourceException.php b/src/Import/Exception/UnknownSourceException.php index b607c65..c799ba3 100644 --- a/src/Import/Exception/UnknownSourceException.php +++ b/src/Import/Exception/UnknownSourceException.php @@ -6,9 +6,6 @@ /** * No source is registered under the requested key. - * - * Separate from EmptySourceException so a caller can tell a key it can fix - * from a source that ran and had nothing to show. */ final class UnknownSourceException extends \InvalidArgumentException { diff --git a/src/Import/Exception/UpsertFailedException.php b/src/Import/Exception/UpsertFailedException.php index 1e18c24..de80c2a 100644 --- a/src/Import/Exception/UpsertFailedException.php +++ b/src/Import/Exception/UpsertFailedException.php @@ -6,9 +6,6 @@ /** * The payload was built, but the broker could not be written to. - * - * Reusing the underlying message keeps whatever the broker said, and the - * original exception stays available as the cause. */ final class UpsertFailedException extends \RuntimeException { From c05adfedb323e8ee32468cf3e7c37c2ab39106c9 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 14:41:32 +0200 Subject: [PATCH 55/59] Removed task broker:entities for now --- .env | 5 ----- README.md | 1 - Taskfile.yml | 8 -------- task/scripts/broker-entities | 34 ---------------------------------- 4 files changed, 48 deletions(-) delete mode 100755 task/scripts/broker-entities diff --git a/.env b/.env index b68e4c9..ee8aa28 100644 --- a/.env +++ b/.env @@ -38,11 +38,6 @@ APP_BROKER_BASE_URI=http://scorpio.local:9090/ # Smart Data Models ones. ENTER_NGSI_CONTEXT_URLS='https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld' -# A single domain context, for the Link header that read requests need. Only -# used by `task broker:entities`, and it must be the context defining the type -# being read — Parking while that is the only model published. -ENTER_NGSI_DOMAIN_CONTEXT=https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld - # Where each feed is read from is not configured here. It belongs to the data # set rather than to the environment, and lives in config/sources.yaml — see # docs/adr/007-source-manifest.md. diff --git a/README.md b/README.md index 3957294..c80ab34 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ source feed (JSON) task import # list the available sources task import -- mtm_spatialmaps-handicap-parking # import one task import -- mtm_spatialmaps-handicap-parking --dry-run --limit 5 # print the payload instead -task broker:entities -- OnStreetParking 10 # read back what landed ``` ### Source manifest diff --git a/Taskfile.yml b/Taskfile.yml index b5cfede..84fbd88 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,14 +40,6 @@ tasks: desc: 'Import a source into the broker, e.g. task import -- mtm_spatialmaps-handicap-parking' cmd: ddev console app:import {{.CLI_ARGS}} - broker:entities: - desc: 'List broker entities of a type, e.g. task broker:entities -- OnStreetParking 10' - cmd: >- - ddev exec sh -c - "APP_BROKER_BASE_URI='$APP_BROKER_BASE_URI' - ENTER_NGSI_DOMAIN_CONTEXT='$ENTER_NGSI_DOMAIN_CONTEXT' - sh task/scripts/broker-entities {{.CLI_ARGS}}" - coding-standards:apply: desc: 'Apply coding standards' cmds: diff --git a/task/scripts/broker-entities b/task/scripts/broker-entities deleted file mode 100755 index 280bb6c..0000000 --- a/task/scripts/broker-entities +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# List entities of a given type from the NGSI-LD broker. -# -# Read requests need a domain context supplied in a Link header; without it the -# broker cannot expand a short type name into the full term the entity was -# written under. The context comes from ENTER_NGSI_DOMAIN_CONTEXT, which must -# be the one defining the type being asked for. -# -# Usage: broker-entities [limit] - -set -eu - -if [ $# -lt 1 ]; then - echo "Usage: broker-entities [limit]" >&2 - echo " NGSI-LD entity type, e.g. OnStreetParking" >&2 - echo " [limit] maximum entities to return (default 100)" >&2 - exit 64 -fi - -TYPE="$1" -LIMIT="${2:-100}" - -RESPONSE=$(curl -sS \ - -H "Link: <${ENTER_NGSI_DOMAIN_CONTEXT}>; rel=\"http://www.w3.org/ns/json-ld#context\"; type=\"application/ld+json\"" \ - "${APP_BROKER_BASE_URI%/}/ngsi-ld/v1/entities?type=${TYPE}&limit=${LIMIT}") - -printf '%s\n' "$RESPONSE" - -# A misspelled type, or one whose context is not the one configured, returns an -# empty list with HTTP 200 — indistinguishable from an empty broker or a failed -# import. Say what was asked for so the difference is visible. -if [ "$(printf '%s' "$RESPONSE" | tr -d '[:space:]')" = "[]" ]; then - printf '\nNo entities of type "%s". A type that does not match, or one defined in a context other than the configured ENTER_NGSI_DOMAIN_CONTEXT, returns an empty list rather than an error — check both before concluding the import failed.\n' "$TYPE" >&2 -fi From a97f6020b72a719e0c6fc559a9f4a6768e390f76 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 14:42:35 +0200 Subject: [PATCH 56/59] Removed comment from .env --- .env | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.env b/.env index ee8aa28..819e914 100644 --- a/.env +++ b/.env @@ -37,8 +37,4 @@ APP_BROKER_BASE_URI=http://scorpio.local:9090/ # context resolves the NGSI-LD terms and the domain context resolves the # Smart Data Models ones. ENTER_NGSI_CONTEXT_URLS='https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld' - -# Where each feed is read from is not configured here. It belongs to the data -# set rather than to the environment, and lives in config/sources.yaml — see -# docs/adr/007-source-manifest.md. ###< app ### From 346cbc6fec91d661478c46de6fd748ccf6a62903 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 14:43:01 +0200 Subject: [PATCH 57/59] Cut some comments --- src/Source/MtmSpatialMaps/HandicapParking.php | 8 ++------ src/Source/SourceInterface.php | 9 ++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index b170ff0..1612cf0 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -15,7 +15,6 @@ * Disabled parking bays in Aarhus Municipality. * * @see config/sources.yaml - * @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking */ final readonly class HandicapParking implements SourceInterface { @@ -48,12 +47,12 @@ public function entities(): iterable } /** + * Maps one feed record onto an NgsiEntity. + * * @param array $feature GeoJSON Feature */ private function toEntity(array $feature, Descriptor $source): ?NgsiEntity { - // A Feature keeps its attributes under `properties` and its geometry - // beside them, so neither is at the feature's top level. $row = $feature['properties'] ?? null; $geometry = $feature['geometry'] ?? null; @@ -74,9 +73,6 @@ private function toEntity(array $feature, Descriptor $source): ?NgsiEntity $source->model ); - // `forDisabled` rather than ParkingGroup's `onlyDisabled`: the two - // models have separate category enums, so values are not interchangeable. - // `onStreet` is dropped because the entity type already states it. return $entity ->setProperty('name', $this->address($row)) ->setProperty('description', trim((string) ($row['bemrk'] ?? ''))) diff --git a/src/Source/SourceInterface.php b/src/Source/SourceInterface.php index eaea6a7..f985048 100644 --- a/src/Source/SourceInterface.php +++ b/src/Source/SourceInterface.php @@ -8,12 +8,11 @@ use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; /** - * One input data set, converted to NGSI-LD entities. + * Converts one input data set into NGSI-LD entities. * - * Implementations own everything specific to their feed: where it comes from, - * its field names, its quirks, and which Smart Data Model it maps onto. The - * broker and the import stay unaware of all of it, so adding the next ENTER - * data set means adding one class and nothing else. + * An implementation owns its feed's origin, field names, quirks and target + * Smart Data Model. The broker and the import know none of that, so a new + * ENTER data set costs exactly one class. */ #[AutoconfigureTag('app.source')] interface SourceInterface From 3353d0869c965837e8126ab4421e415688a2dea0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 14:43:28 +0200 Subject: [PATCH 58/59] Coding standards --- src/Import/DataSourceImporter.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Import/DataSourceImporter.php b/src/Import/DataSourceImporter.php index 3030870..f2a7fa7 100644 --- a/src/Import/DataSourceImporter.php +++ b/src/Import/DataSourceImporter.php @@ -105,6 +105,7 @@ public function import(string $key, ?int $limit = null): ImportResult * Get list of registered datasets. * * @see config/sources.yaml + * * @return array keyed by source key */ private function registry(): array @@ -119,8 +120,10 @@ private function registry(): array } /** - * Return an array of contexts. Each dataset holds its own context + * Return an array of contexts. Each dataset holds its own context. + * * @see config/sources.yaml + * * @return array */ private function contexts(string $key): array From 388d88a463c5f3125fcb44cc77f5d2d1345f7553 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 8 Sep 2026 14:57:57 +0200 Subject: [PATCH 59/59] ADR shortening --- docs/adr/001-architecture-symfony-docker.md | 75 ++++------ docs/adr/002-publish-to-a-context-broker.md | 120 +++++++-------- docs/adr/003-ngsi-ld-representation.md | 47 +++--- docs/adr/004-coordinate-reference-system.md | 139 ++++++++---------- .../005-smart-data-models-as-vocabulary.md | 134 +++++++---------- .../006-onstreetparking-over-parkinggroup.md | 87 +++++------ 6 files changed, 249 insertions(+), 353 deletions(-) diff --git a/docs/adr/001-architecture-symfony-docker.md b/docs/adr/001-architecture-symfony-docker.md index c0f8708..1431f7b 100644 --- a/docs/adr/001-architecture-symfony-docker.md +++ b/docs/adr/001-architecture-symfony-docker.md @@ -10,61 +10,49 @@ ## Context -The adapter reads open data sets, converts them to a standard smart-city -representation, and publishes them to a context broker. It needs a runtime, an -HTTP client, a console for running imports, and a local development environment -including a broker to import into. It has no web UI and no domain data of its -own. - -ITK Dev maintains a fleet of PHP services with an established Docker-based -development convention, expressed as versioned project templates with shared CI -and coding-standards configuration. A new application either adopts that or -diverges from it. - -This ADR serves to decide the runtime, framework and development -environment the application is built on. +This application reads open data sets, converts them to a standard smart-city +representation, and publishes them to a context broker. The organisation +maintains its PHP services on a versioned Docker template carrying shared CI +and coding-standards configuration. This ADR serves to decide the runtime, +framework and development environment the application is built on. ### Drivers -- **Functional:** scheduled console commands; outbound HTTP; a local broker. - No database and no HTTP surface of its own. -- **Non-functional:** minimal onboarding cost; shared tooling rather than - reimplemented tooling; reproducible across developers and CI; long-term - vendor support. +- **Functional:** scheduled console commands, outbound HTTP, and a local broker + to import into. No database and no HTTP surface of its own. +- **Non-functional:** shared tooling rather than reimplemented tooling, minimal + onboarding, reproducible across developers and CI, long-term vendor support. ### Options Considered -1. **PHP 8.4 / Symfony 8 on the ITK Dev `symfony-8` template.** Matches the - organisation's existing stack, so CI, coding standards and task runner come - for free; the console component suits scheduled imports. Provisions a web - server, database and mail catcher this application never uses, and its PHP - version runs ahead of developer hosts, making containers mandatory. -2. **Minimal framework project without the template, run on the host.** No - unused services, no container requirement for the application — but shared - CI and coding-standards config would be reimplemented by hand, and a local - broker needs containers anyway, so the dependency is moved rather than - removed. -3. **A second entry point in an existing internal application.** One - deployment to operate, but couples a batch importer's release cycle to a - user-facing application and inherits dependencies it has no use for. +1. **PHP 8.4 / Symfony 8 on the maintained template.** CI, coding standards and + task runner come for free, and its console suits scheduled imports; it + provisions services this application never uses, and its PHP runs ahead of + developer hosts. +2. **A minimal project on the host, without the template.** No unused services + and no container requirement, but shared configuration is rebuilt by hand + and a local broker needs containers anyway, moving the requirement rather + than removing it. +3. **A second entry point in an existing internal application.** One deployment + to operate, but couples a batch importer to a user-facing release cycle and + inherits dependencies it has no use for. 4. **A different language ecosystem on a bespoke setup.** Richer geospatial - libraries in some ecosystems, but no internal expertise and no shared - tooling. The transformations needed are available as mature libraries in the - established stack too. + libraries, but no internal expertise and no shared tooling; the needed + transformations exist as mature libraries in the established stack. ## Decision -**PHP 8.4 + Symfony 8** on the ITK Dev `symfony-8` template, as its **own -deployable service**, with a containerised broker overlay for local development. +**PHP 8.4 + Symfony 8** on the ITK Dev Docker template, as its **own deployable +service**, with a containerised broker overlay for local development. - Standardising costs less over the application's lifetime than trimming unused - services. A second toolchain must be learned and patched; idle containers + services: a second toolchain must be learned and patched; idle containers cost only disk. -- A batch importer and a user-facing application have different lifecycles and - failure modes, so they stay separate services. +- A batch importer's lifecycle and failure modes differ from a user-facing + application's, so it stays its own service. - No domain persistence is needed — the broker is the system of record — so the - template's database service is left unused rather than removed, keeping - template updates a clean diff. + template's database is left unused rather than removed, keeping template + updates a clean diff. - Local development includes a real broker, so imports are verified end to end rather than only as serialised output. @@ -78,9 +66,8 @@ deployable service**, with a containerised broker overlay for local development. ### Negative / Trade-offs -- **Containers are mandatory.** The template's PHP runs ahead of developer - hosts, so dependency management, console commands and tests cannot run - natively. Most likely source of first-run confusion. +- Containers are mandatory; dependency management, console commands and tests + cannot run natively. - A web server, database and mail catcher are provisioned and never used. - Broker images are not published for every CPU architecture, so local start-up may be slow under emulation. diff --git a/docs/adr/002-publish-to-a-context-broker.md b/docs/adr/002-publish-to-a-context-broker.md index 7372479..a1225c6 100644 --- a/docs/adr/002-publish-to-a-context-broker.md +++ b/docs/adr/002-publish-to-a-context-broker.md @@ -10,69 +10,54 @@ ## Context -The adapter makes data available to consumers the organisation does -not control and cannot brief. The data already exists in operational systems, -with heterogeneous formats, coordinate systems and access methods. What has to -be decided is the mechanism by which it is published. The source systems remain -authoritative; the published copy is not a system of record. - -This ADR serves to decide the mechanism by which data is published to -consumers. +This application republishes data held in operational systems, in heterogeneous +formats and coordinate systems, to consumers the organisation neither controls +nor can brief. The source systems stay authoritative; the published copy is not +a system of record. This ADR serves to decide the mechanism by which data is +published to consumers. ### Drivers -- **Functional:** query by location and attribute, not bulk download only; - several data sets reaching one consumer-facing surface; change notification; - adding a data set without changing consumer integrations. -- **Non-functional:** interpretable by consumers we have never spoken to; - operational cost proportionate to the data and the number of consumers; - existing client tooling rather than clients we supply; an interface that - outlives individual data sets. +- **Functional:** query by location and attribute rather than bulk download + only, several data sets on one consumer-facing surface, change notification, + and adding a data set without changing consumer integrations. +- **Non-functional:** interpretable by consumers never spoken to, served through + client tooling that already exists, operational cost proportionate to the data + and its consumers, and an interface that outlives individual data sets. ### Options Considered -1. **An NGSI-LD context broker.** Provides geospatial and attribute queries, - pagination, subscriptions and a temporal interface without implementing - them; payloads carry a vocabulary reference, so they are self-describing; - many producers converge on one consumer surface. Substantial operational - weight — typically a database and message bus alongside the broker — and the - strictness of a particular implementation is inherited. -2. **Static file export** on a web server or object store. Near-zero - operational cost, trivially cacheable, readable by anything. No query, so - consumers download everything and filter client-side; no change - notification; conventions must be documented in prose because nothing in the - file declares its own meaning. -3. **A bespoke REST API** over our own datastore. Exact fit, full control of - the query surface and semantics. Every capability is ours to build and - maintain — geo-queries, filtering, pagination, notifications, documentation, - clients, versioning — and consumers must learn an interface that exists - nowhere else. -4. **Direct database access or a read replica.** No API layer, powerful ad-hoc - querying. Exposes internal schema as a public contract, requires per-consumer +1. **An NGSI-LD context broker.** Geospatial and attribute queries, pagination, + subscriptions and a temporal interface without implementing them, and + payloads carrying a vocabulary reference; substantial operational weight, and + the strictness of a particular implementation is inherited. +2. **A bespoke REST API** over a datastore of our own. Exact fit and full + control of the query surface and its semantics; every capability from + geo-queries to notifications, clients and versioning is ours to build, and + consumers must learn an interface that exists nowhere else. +3. **Direct database access or a read replica.** No API layer, powerful ad-hoc + querying; exposes internal schema as a public contract, needs per-consumer credentials and network access, and is unusable by browser-based consumers. ## Decision Publish to an **NGSI-LD context broker**. -- Consumers of geographic data need "everything within this area" and - "everything of this kind" more often than the whole data set. A broker - provides that as a standard interface rather than a per-data-set feature. -- Publishing structure without a vocabulary reference requires every consumer - to have our documentation. A broker payload carries the reference. -- For a single small data set a static export would be cheaper and better. Once - several heterogeneous data sets must be published, the fixed operational cost - is paid once while the per-data-set cost approaches zero, and consumers - integrate once rather than once per source. -- New consumers require no change to the adapter, and new data sets require no - change to consumers. -- Existing viewers, dashboards and connectors speak this interface; a bespoke - API would mean supplying clients indefinitely. - -The broker's value is interoperability and query, not storage. If no consumer -reads the data through its interface, a static export would have been the better -decision. Revisit once data sets have been published long enough for consumers -to appear. +1. Consumers of geographic data ask for an area or a kind more often than for a + whole data set, and a broker offers that as a standard interface rather than + a per-data-set feature. +2. Structure published without a vocabulary reference obliges every consumer to + hold separate documentation; a broker payload carries the reference. +3. A single small data set would be served better by a static export. Across + several heterogeneous ones the fixed operational cost is paid once, the + per-data-set cost approaches zero, and consumers integrate once rather than + once per source. +4. New consumers require no change here, new data sets none from consumers, and + existing viewers, dashboards and connectors already speak this interface. + +The value taken is interoperability and query, not storage. Revisit once +consumers have had time to appear: if none read the data through the interface, +a static export was the better decision. ## Consequences @@ -80,25 +65,22 @@ to appear. - Geospatial and attribute queries, pagination, subscriptions and a temporal interface, none of which are implemented here. -- Payloads reference a shared vocabulary, so they need no bespoke - documentation. +- Payloads reference a shared vocabulary, so they need no bespoke documentation. - Additional data sets reach every existing consumer with no integration work. -- The adapter has no database and no read surface of its own. +- This application keeps no database and no read surface of its own. ### Negative / Trade-offs -- **Operational weight out of proportion to a small data set.** A broker - deployment is several services to run, patch, monitor and back up. For a - static data set a file on a web server would serve the same need. -- **Broker implementations impose constraints beyond the standard.** Those - encountered include accepting only one spelling of a UTC timestamp while - rejecting an equivalent one, requiring the vocabulary reference on read - requests — with omission returning an empty success rather than an error — - and collapsing single-element lists to scalars. -- **Vocabulary documents may be fetched over the network during writes**, so - third-party availability becomes part of the import path. -- **No delete semantics.** Upsert creates and updates but never removes, so - records that disappear upstream persist until reconciliation is built. -- JSON-LD is a learning curve for maintainers and consumers. -- Fitting data to a shared vocabulary costs effort that publishing as-is - would not. +- Operational weight out of proportion to a small data set: several services to + run, patch, monitor and back up. +- Broker implementations impose constraints beyond the standard. Those met + include one accepted spelling of a UTC timestamp while an equivalent is + rejected, the vocabulary reference required on reads where omission returns an + empty success rather than an error, and single-element lists collapsed to + scalars. +- Vocabulary documents may be fetched over the network during writes, making + third-party availability part of the import path. +- Upsert creates and updates but never removes, so records that disappear + upstream persist until reconciliation is built. +- JSON-LD is a learning curve for maintainers and consumers, and fitting data to + a shared vocabulary costs effort that publishing as-is would not. diff --git a/docs/adr/003-ngsi-ld-representation.md b/docs/adr/003-ngsi-ld-representation.md index 8adcb7e..ffbb16f 100644 --- a/docs/adr/003-ngsi-ld-representation.md +++ b/docs/adr/003-ngsi-ld-representation.md @@ -10,57 +10,52 @@ ## Context -ADR 002 chooses an NGSI-LD context broker. That leaves two representation -choices open: how attributes are shaped, and which write operation is used. +ADR 002 chooses an NGSI-LD context broker, which leaves the representation +open. -NGSI v2, the older FIWARE API generation, was not considered viable: it has no -`@context`, so a shared vocabulary cannot be expressed, and the -organisation operates no v2 broker. - -This ADR serves to decide how attributes are shaped and which write -operation is used. +This ADR serves to decide how attributes are shaped and which write operation +is used. ### Drivers -- **Functional:** the broker must accept the payload on write; attribute-level - metadata must be expressible; repeated imports must not duplicate entities. -- **Non-functional:** idempotency; payload size; readability for consumers. +- **Functional:** the broker must accept the payload on write, attribute + metadata must be expressible, and repeated imports must not duplicate + entities. +- **Non-functional:** idempotency, payload size, readability for consumers. ### Options Considered #### Attribute form -1. **Normalized** — every attribute an object naming its kind. Verbose, but - the form brokers accept on write and the only one carrying metadata such as - `observedAt`. -2. **Key-values** — flat `name: value`. Much smaller and easier to read, but - read-only and cannot carry attribute metadata. +1. **Normalized.** Every attribute an object naming its kind. Verbose, but the + form brokers accept on write and the only one carrying metadata. +2. **Key-values.** Flat `name: value`, smaller and easier to read, but + read-only and unable to carry attribute metadata. #### Write operation -1. **Batch upsert** — creates or updates. Idempotent when identifiers are - derived from source keys. -2. **Create** — fails with `409` for identifiers that already exist, so a +1. **Batch upsert.** Creates or updates, so a re-import refreshes in place. +2. **Create.** Fails with `409` for an identifier that already exists, so a re-import errors rather than refreshing. -3. **Batch replace** — silently drops attributes absent from the payload, - making partial payloads destructive. +3. **Batch replace.** Silently drops attributes absent from the payload, + making a partial payload destructive. ## Decision Publish **normalized** NGSI-LD with `Content-Type: application/ld+json`, via **batch upsert**. -- Normalized is the only form accepted on write, so key-values is not a real - option for a producer. -- Upsert makes imports idempotent: identifiers derive from each source's - primary key, so re-running updates in place. +- Normalized is the only form accepted on write, so key-values is not + available to a producer. +- Identifiers derive from each data set's primary key, so upsert makes a + re-run update in place rather than add. ## Consequences ### Positive - Re-imports produce no duplicates and need no prior state. -- Attribute metadata remains available if a source ever supplies it. +- Attribute metadata stays expressible where a data set supplies it. ### Negative / Trade-offs diff --git a/docs/adr/004-coordinate-reference-system.md b/docs/adr/004-coordinate-reference-system.md index 177d6b7..f77f88a 100644 --- a/docs/adr/004-coordinate-reference-system.md +++ b/docs/adr/004-coordinate-reference-system.md @@ -11,102 +11,83 @@ ## Context Every entity the adapter publishes carries a `location` GeoProperty, so the -coordinate reference system is a cross-cutting concern rather than a per-source -detail. +coordinate reference system is cross-cutting rather than a per-input detail. +Inputs arrive in whatever CRS their publisher uses — Danish municipal data is +commonly projected, typically EPSG:25832 (ETRS89 / UTM zone 32N) as eastings and +northings in metres — and no single input CRS can be assumed. A GeoJSON envelope +states a geometry type but not units, so projected coordinates arrive inside one +undetected. -Input data arrives in whatever CRS its publisher uses. Danish municipal data is -commonly projected — typically EPSG:25832 (ETRS89 / UTM zone 32N), as eastings -and northings in metres. Other inputs may already be geographic, or use a -different projection. The adapter cannot assume one input CRS. - -Projected coordinates are sometimes delivered inside a GeoJSON envelope, which -states a geometry type but not units. The data models specify only that -`location` is GeoJSON and make no reference to a coordinate system; the -constraint comes from GeoJSON itself. - -This ADR serves to decide which coordinate reference system is published, -and at what precision. +This ADR serves to decide which coordinate reference system is published, and at +what precision. ### Drivers -- **Functional:** consumers must be able to interpret `location` unbriefed; - geo-queries must return correct results; clients must render without - preprocessing; one rule must hold for every input. -- **Non-functional:** self-description; conformance; uniformity across inputs; - precision no worse than the input. +- **Functional:** `location` is interpretable unbriefed, geo-queries return + correct results, and clients render without preprocessing. +- **Non-functional:** conformance and self-description; one rule for every + input; precision no worse than the input. ### Options Considered -1. **Normalise everything to WGS84, reprojecting in the adapter.** Conforms to - RFC 7946; self-describing; geo-queries work; one rule however many input - CRSs accumulate. Requires a reprojection dependency, and each input must - declare its CRS. -2. **Pass each input's native CRS through unchanged.** No transformation, no - dependency — but produces invalid GeoJSON with nowhere to declare the CRS, - makes entities from different inputs mutually incomparable, and breaks - geo-queries because distances are read as degrees. Every failure is silent. -3. **Publish WGS84 and also retain original coordinates in an extra - attribute.** Avoids a round trip for consumers wanting native coordinates, - but the attribute cannot have a stable shape: each input brings its own CRS - and geometry type, and it would be absent for inputs already in WGS84. A - consumer cannot code against that, so it would go unused. -4. **Pass native CRSs through under RFC 7946's "prior arrangement" clause, - documenting each out of band.** Permitted by the RFC, but the clause - requires all parties to have agreed — incompatible with a broker whose - consumers are unknown by design. +1. **Normalise everything to WGS84, reprojecting in the adapter.** One rule + however many input CRSs accumulate. Needs a reprojection dependency, and each + input must declare its CRS. +2. **Pass each input's native CRS through unchanged.** No transformation and no + dependency, but the GeoJSON is invalid, entities from different inputs are + incomparable, and distances read as degrees. +3. **Publish WGS84 and also keep the original coordinates in an extra + attribute.** Saves consumers a round trip, but the attribute has no stable + shape — CRS and geometry type differ per input, and it is absent for inputs + already in WGS84 — so nothing can be coded against it. +4. **Pass native CRSs through under RFC 7946's "prior arrangement" clause.** + Permitted, but the clause requires all parties to have agreed, which a broker + whose consumers are unknown by design cannot satisfy. ## Decision -Every `location` the adapter emits is **EPSG:4326 (WGS84) -longitude/latitude**, at **full precision — coordinates are not rounded**. Each -input declares its own CRS; reprojection happens at the boundary between reading -an input and building an entity, and nowhere else. - -- **The specification is unambiguous.** RFC 7946 §4: "The coordinate reference - system for all GeoJSON coordinates is a geographic coordinate reference - system, using the World Geodetic System 1984 (WGS 84) datum, with longitude - and latitude units of decimal degrees." NGSI-LD GeoProperty values are - GeoJSON, so the requirement is inherited. -- **There is no way to declare otherwise.** RFC 7946 Appendix B.1: - "Specification of coordinate reference systems has been removed, i.e., the - 'crs' member of [GJ2008] is no longer used." Publishing projected coordinates - means publishing an undeclarable assumption. Inputs may still carry that - deprecated member; it can be read, but not passed on. -- Entities from different inputs are queried together, so a query spanning two - inputs published in different CRSs returns meaningless results. -- A broker given projected coordinates accepts them, answers geo-queries - incorrectly, and renders points in the wrong location. No error is raised at - any stage. -- **The conversion is lossless at full precision.** A projected-to-geographic - round trip returns the input exactly when no rounding is applied. Rounding - trades accuracy for a marginal reduction in payload size. -- `source` and `seeAlso` can reference the originating export, which states its - own CRS. A coordinate copied into an extra attribute states nothing. +Every `location` the adapter emits is **EPSG:4326 (WGS84) longitude/latitude**, +at **full precision — coordinates are not rounded**. Each input declares its own +CRS; reprojection happens at the boundary between reading an input and building +an entity, and nowhere else. + +1. **The specification leaves no choice.** RFC 7946 §4 fixes the CRS for all + GeoJSON coordinates as geographic, WGS 84 datum, longitude and latitude in + decimal degrees; NGSI-LD GeoProperty values are GeoJSON and inherit it. +2. **There is no way to declare otherwise.** RFC 7946 Appendix B.1 removed the + `crs` member of the 2008 specification, so projected coordinates publish an + undeclarable assumption. An input may still carry that deprecated member; it + can be read, not passed on. +3. Nothing catches the mistake: a broker accepts projected coordinates, answers + geo-queries incorrectly and places points wrongly, raising no error; and + entities from different inputs are queried together, so a query spanning two + CRSs returns meaningless results. +4. **A round trip is lossless at full precision.** Projected to geographic and + back returns the input exactly when nothing is rounded; rounding trades + accuracy for a marginal payload reduction. +5. `source` and `seeAlso` can reference the originating export, which states its + own CRS. ## Consequences ### Positive -- Payloads are valid GeoJSON and NGSI-LD; geo-queries work and are comparable - across inputs; any client renders them unmodified. -- One rule for every present and future input. -- Reprojection is isolated in one component with its own tests, verified - against independently known reference coordinates, so a regression fails - loudly instead of silently relocating data. +- Payloads are valid GeoJSON and NGSI-LD, comparable across inputs, and render + in any client unmodified, under one rule for every present and future input. +- Reprojection is isolated in one tested component, verified against + independently known reference coordinates, so a regression fails loudly rather + than silently relocating data. ### Negative / Trade-offs -- Adds a reprojection dependency. National grid definitions are not always - shipped and may need registering explicitly, making them load-bearing - project code. -- **Datum shifts are approximated.** ETRS89-based grids are treated as - equivalent to WGS84 via a null datum transformation. The two were coincident - in 1989 and have diverged by roughly 0.5–1 m since, at about 2.5 cm per year. - What is published is therefore ETRS89 labelled WGS84. This is standard - practice in web GIS, but it is the largest error in the pipeline — greater - than the source's own positional accuracy — so a consumer using a rigorous - transformation with an explicit epoch will land about a metre away. +- Adds a reprojection dependency, and national grid definitions are not always + shipped, so registering them becomes load-bearing project code. +- Datum shifts are approximated: ETRS89-based grids are treated as equivalent + to WGS84 via a null datum transformation, so what is published is ETRS89 + labelled WGS84. Coincident in 1989, the two have diverged by roughly 0.5–1 m + at about 2.5 cm per year — standard practice in web GIS, and the largest + error introduced, so a consumer transforming rigorously with an explicit + epoch lands about a metre away. - Consumers with natively projected stacks must convert. - Every new input must declare its CRS, and unsupported ones need adding. -- Only point geometries were implemented initially; line and area geometries - were added later. +- Each geometry type needs its own reprojection, not points alone. diff --git a/docs/adr/005-smart-data-models-as-vocabulary.md b/docs/adr/005-smart-data-models-as-vocabulary.md index 42f11ec..7cba550 100644 --- a/docs/adr/005-smart-data-models-as-vocabulary.md +++ b/docs/adr/005-smart-data-models-as-vocabulary.md @@ -10,117 +10,87 @@ ## Context -NGSI-LD defines how attributes are carried and how to reference a vocabulary. -It does not define entity types or attribute names. Without a vocabulary the -JSON-LD context resolves to nothing, entity types are local strings, and -consumers still need our documentation to interpret anything. - -The choice also determines the cost of onboarding each data set, since mapping -a source onto an existing model takes more effort than exposing its fields -verbatim. - -This ADR serves to decide which vocabulary supplies entity types and +NGSI-LD defines how attributes are carried and how a vocabulary is referenced, +not which entity types and attribute names exist. Without one the JSON-LD +context resolves to nothing and consumers need our documentation to interpret +anything. This ADR serves to decide which vocabulary supplies entity types and attribute names, and the rules for mapping sources onto it. ### Drivers -- **Functional:** types and attributes interpretable without our - documentation; expressible as a JSON-LD context a broker can resolve; - coverage across the domains in scope. -- **Non-functional:** a vocabulary consumers plausibly already know; governed - and maintained by someone else; mapping cost that does not dominate - onboarding. +- **Functional:** types and attributes interpretable without our documentation, + expressible as a JSON-LD context a broker can resolve, covering the domains in + scope. +- **Non-functional:** a vocabulary consumers plausibly already know, maintained + by someone else, at a mapping cost that does not dominate onboarding. ### Options Considered -1. **Smart Data Models.** Purpose-built for NGSI-LD and the reference - vocabulary of that ecosystem; publishes JSON-LD context documents per - domain; broad coverage; each model ships a JSON schema and examples, giving - an objective conformance target; open governance. Model depth varies; - many models assume real-time sensing, so static inventory leaves attributes - unset; required attributes occasionally presuppose a hierarchy the source - lacks; enum spellings sometimes disagree between a model's schema and its - examples; versioning is loose. -2. **A vocabulary of our own, with self-hosted context documents.** Exact fit, - no required attributes we cannot satisfy, full control of naming and - versioning. Nobody else speaks it, so consumers return to reading our - documentation; governance, documentation and versioning become ours - indefinitely; no existing tooling recognises the types. -3. **A general-purpose web vocabulary.** Widely recognised, stable governance, - adequate for names, addresses and descriptions. No NGSI-LD conventions for - geometry or relationships, and no domain-specific terms, so the domains in - scope would remain unmodelled. +1. **Smart Data Models.** The openly governed reference vocabulary of the + NGSI-LD ecosystem, with broad coverage, per-domain context documents, and a + schema and examples per model to conform against. Depth varies, many models + assume real-time sensing, required attributes can presuppose a hierarchy a + source lacks, and versioning is loose. +2. **A vocabulary of our own, with self-hosted context documents.** Exact fit + and full control of naming and versioning, but nobody else speaks it, + governance and documentation stay ours indefinitely, and no existing tooling + recognises the types. +3. **A general-purpose web vocabulary.** Widely recognised and stably governed, + adequate for names, addresses and descriptions, but with no NGSI-LD + conventions for geometry or relationships and no domain terms, leaving the + domains in scope unmodelled. ## Decision Adopt **Smart Data Models**, referencing the relevant domain context documents -alongside the NGSI-LD core context. - -Two rules follow, and they matter more than the choice itself: +alongside the NGSI-LD core context. Which model a given data set uses is +recorded in its own ADR; this one states policy. 1. **Use an existing model; do not invent a type.** An imperfect standard type - is more useful to a consumer than a perfect private one. + is more useful to a consumer than a precise private one. 2. **Never fabricate a value to satisfy a model.** An absent attribute is - honest; a fabricated one is indistinguishable from a measured one. Applied: - + honest; a fabricated one is indistinguishable from a measured one. - Attributes the source cannot fill are left unset, not approximated, defaulted or inferred. - Where a model *requires* an attribute the source cannot supply, choose a - different model rather than inventing the value. Required relationships - are the common case: inventing a related entity yields something - schema-valid and factually wrong that must then be maintained - indefinitely. A sibling model without the requirement is the better - choice even if its terms are less precise. - - In a hierarchy — a root site, subdivisions beneath it, individual units - beneath those, each lower level requiring a relationship upward — publish - at the highest level the source can populate. Static inventory typically - describes a location and a count of units without describing what the - location is part of, so the site level is usually correct. - -The concrete model chosen for a given data set is recorded in its own ADR; this -one states policy only. + sibling model without the requirement, even if its terms are less precise. + - In a hierarchy where each level requires a relationship upward, publish at + the highest level the source can populate — typically the top, for a data + set giving a location and a count of units within it. Rationale: -- The context must resolve to terms a consumer recognises, or publishing gains - nothing over a file. -- Smart Data Models is the vocabulary the surrounding ecosystem uses and ships - the context documents needed to reference it, so adoption is a URL rather - than a project. +- A context must resolve to terms a consumer recognises, or publishing gains + nothing over a file, and Smart Data Models ships those documents, so adoption + is a URL rather than a project. - Shipped schemas and examples make modelling disagreements checkable against a specification instead of settled by preference. -- Mandatory relationships propagate downward: choosing a subdivision level - schedules the need for a parent rather than avoiding it, because the - individual-unit level requires a site as well. -- The costs are asymmetric. Publishing at site level and later finding real - sites exist means a one-off migration. Publishing at subdivision level and - never acquiring real sites means maintaining an invented entity - indefinitely, with every consumer that follows the relationship receiving - something meaningless. +- Required relationships propagate downward, so publishing below the top level + defers the need for a parent rather than avoiding it, and the costs are + asymmetric: acquiring real parents later is a one-off migration, whereas never + acquiring them means maintaining an invented entity that every consumer + following the relationship receives as meaningless. ## Consequences ### Positive -- Types and attributes resolve to shared global identifiers. -- Consumers may already have code for the types published. -- Modelling decisions have an external reference point. -- Later data sets are likely already covered, so onboarding does not start with - vocabulary design. -- Published entities are self-contained, with nothing invented to keep in sync. -- Finer granularity can be added later beneath what is already published. +- Types and attributes resolve to shared global identifiers consumers may + already have code for. +- A later data set is likely covered already, so onboarding it does not start + with vocabulary design. +- Published entities are self-contained, with nothing invented to keep in sync, + and finer granularity can be added beneath them later. ### Negative / Trade-offs -- **Many attributes will always be empty.** Models built around real-time - sensing carry availability, occupancy and detection attributes that static - inventory cannot fill. -- **Model choice is embedded in entity identifiers.** Changing model later - means deleting and re-publishing rather than updating in place, so selection - deserves attention before a data set is first published. -- **Enum values must be read from the schema, not the examples.** Where the two - disagree the schema is authoritative, and equivalent-looking values differ - between sibling models, so they must not be copied across. -- **Loose versioning.** A model can change without an obvious signal. +- Models built around real-time sensing carry attributes static inventory + cannot fill, so many will always be empty. +- Model choice is embedded in entity identifiers, so changing model later means + deleting and re-publishing rather than updating in place. +- Enum values must be read from the schema rather than the examples: where the + two disagree the schema is authoritative, and equivalent-looking values differ + between sibling models, so they cannot be copied across. +- Versioning is loose, so a model can change without an obvious signal. - Mapping a source to a model takes longer than exposing its fields verbatim, and occasionally the fit is poor. diff --git a/docs/adr/006-onstreetparking-over-parkinggroup.md b/docs/adr/006-onstreetparking-over-parkinggroup.md index 8b04272..f72fe3f 100644 --- a/docs/adr/006-onstreetparking-over-parkinggroup.md +++ b/docs/adr/006-onstreetparking-over-parkinggroup.md @@ -10,54 +10,42 @@ ## Context -The parking domain is organised as a hierarchy: - -```text -OnStreetParking / OffStreetParking site — no parent, requires id, type, location - └── ParkingGroup subdivision — requires refParkingSite - └── ParkingSpot individual unit — requires refParkingSite, status, category -``` +The parking domain is a hierarchy. A site — `OnStreetParking` or +`OffStreetParking` — requires only `id`, `type` and `location`. Below it, a +`ParkingGroup` subdivision and a `ParkingSpot` unit must each reference a site, +and a spot also requires `status` and `category`. This decision applies where a data set provides a count of units per location with a point geometry, no reference to a containing site, and neither per-unit geometry nor occupancy. -Entity identifiers embed the type by convention, so the choice must be made -before first publication: changing it afterwards means deleting and -re-publishing. - This ADR serves to decide which model in the parking hierarchy is published under those conditions. ### Drivers -- **Functional:** every mandatory relationship must point at an entity that - exists; the restriction on who may park must be expressible unambiguously; - finer granularity addable later without restructuring what is published. -- **Non-functional:** nothing invented purely to satisfy a schema; a choice - that is cheap to reverse in preference to one that is not. +- **Functional:** every mandatory relationship points at an entity that exists; + the restriction on who may park is expressible unambiguously; finer + granularity is addable later without restructuring. +- **Non-functional:** nothing invented purely to satisfy a schema; a reversible + choice in preference to one that is not. ### Options Considered -1. **`ParkingGroup`, creating the missing parent site.** `category` offers - `onlyDisabled`, which by name states exclusivity, and the model's reference - example for disabled parking sits at this level. But `refParkingSite` is - mandatory and no value is available for it, so a parent must be invented; - one spanning the whole administrative area asserts a false containment, and - its own mandatory geometry would carry no meaning. - `ParkingSpot` also requires a site, so adding per-unit data later would force - the invented entity into existence after entities had been published against - it. +1. **`ParkingGroup`, creating the missing parent site.** `onlyDisabled` states + exclusivity by name, and the model's reference example for disabled parking + sits at this level — but points at a real street-address site. + `refParkingSite` is mandatory with no value available, so a parent must be + invented; one spanning the administrative area asserts a false containment + and its own mandatory geometry carries no meaning. 2. **`ParkingGroup`, omitting `refParkingSite`.** Nothing invented, smallest change — but knowingly non-conformant, and a schema validator flags every entity. -3. **`OnStreetParking`.** Requires only `id`, `type` and `location`, all of - which are available. It is the entity both `ParkingGroup` and - `ParkingSpot` are required to reference, so finer granularity can be - attached beneath it, and migration down to `ParkingGroup` stays possible if - real site data appears. `category` offers only `forDisabled`, which does not - state exclusivity as plainly. -4. **`ParkingSpot`.** Models an individual unit, but only a count per location +3. **`OnStreetParking`.** Everything it requires is available, and it is the + entity both lower levels must reference, so granularity can be added beneath + it and migration downward stays possible if real sites appear. `category` + offers only `forDisabled`, which states exclusivity less plainly. +4. **`ParkingSpot`.** Models the individual unit, but only a count per location is available, `status` is mandatory with no occupancy data, and a parent site is required. Rejected outright. @@ -66,39 +54,32 @@ under those conditions. Publish each record as an **`OnStreetParking`** entity, with `category: ["forDisabled"]` and no `refParkingSite`. -- It is the only option that invents nothing; everything the model requires is - available. -- Mandatory relationships propagate downward, so choosing `ParkingGroup` would - schedule the invented parent rather than avoid it — `ParkingSpot` requires a - site too. +- The only option that invents nothing: everything it requires is available. +- Mandatory references propagate downward, so `ParkingGroup` would schedule the + invented parent rather than avoid it — `ParkingSpot` requires a site too. - The costs are asymmetric. Site level, then finding real sites exist, is a - one-off migration. Subdivision level, then never acquiring real sites, means + one-off migration; subdivision level, then never acquiring real sites, means maintaining an invented entity indefinitely. -The model's reference example does use `ParkingGroup` for disabled parking, but -points at a real street-address site. It shows what to do when a site exists, -not when none does. - ## Consequences ### Positive -- No dangling relationship; every entity is self-contained. -- Nothing invented to create, document or keep in sync. +- No dangling relationship: every entity is self-contained, and nothing invented + has to be created, documented or kept in sync. - Conformant to the model's schema without exceptions. - `ParkingGroup` or `ParkingSpot` entities can be attached beneath these later without changing them. ### Negative / Trade-offs -- **`forDisabled` does not state exclusivity.** The schema documents `category` - only as "Street parking category" with an enum list and defines no individual - value. The two models' enums use inconsistent prefixes for what appear to be - the same concepts — `forDisabled` / `forResidents` against `onlyDisabled` / - `onlyResidents` — while both carry `onlyWithPermit`, so the spelling cannot - be relied on to carry exclusivity. -- Values must not be copied between the two models' `category` enums. -- Entity type is embedded in identifiers, so any later change means deleting - and re-publishing. +- `forDisabled` does not state exclusivity. The schema documents `category` only + as "Street parking category" with an enum list and defines no individual + value, and the two models prefix apparently identical concepts inconsistently + — `forDisabled` / `forResidents` against `onlyDisabled` / `onlyResidents` — + while both carry `onlyWithPermit`, so values must not be copied between the + enums. +- Entity type is embedded in identifiers, so any later change means deleting and + re-publishing. - Domain experts may find a single address described as a "site" counter-intuitive.