diff --git a/.dockerignore b/.dockerignore index 870f3610..beca6373 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +bin/rr config/autoload/*local* data/infra data/cache/* @@ -22,4 +23,4 @@ infection* **/test* build* **/.* -bin/helper +!config/roadrunner/.rr.yml diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml new file mode 100644 index 00000000..78cbdf1c --- /dev/null +++ b/.github/actions/ci-setup/action.yml @@ -0,0 +1,47 @@ +name: CI setup +description: 'Sets up the environment to run CI actions for Shlink' + +inputs: + install-deps: + description: 'Tells if dependencies should be installed with composer. Default value is "yes"' + required: true + default: 'yes' + php-version: + description: 'The PHP version to be setup' + required: true + php-extensions: + description: 'The PHP extensions to install' + required: false + default: '' + extensions-cache-key: + description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled' + required: true + +runs: + using: composite + steps: + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ inputs.php-version }} + extensions: ${{ inputs.php-extensions }} + key: ${{ inputs.extensions-cache-key }} + - name: Cache extensions + uses: actions/cache@v2 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + tools: composer + extensions: ${{ inputs.php-extensions }} + coverage: pcov + ini-values: pcov.directory=module + - name: Install dependencies + if: ${{ inputs.install-deps == 'yes' }} + run: composer install --no-interaction --prefer-dist + shell: bash diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml new file mode 100644 index 00000000..09543610 --- /dev/null +++ b/.github/workflows/ci-db-tests.yml @@ -0,0 +1,44 @@ +name: Database tests + +on: + workflow_call: + inputs: + platform: + type: string + required: true + description: One of sqlite:ci, mysql, maria, postgres or ms + +jobs: + db-tests: + runs-on: ubuntu-22.04 + strategy: + matrix: + php-version: [ '8.1' ] + env: + LC_ALL: C + steps: + - uses: actions/checkout@v3 + - name: Install MSSQL ODBC + if: ${{ inputs.platform == 'ms' }} + run: sudo ./data/infra/ci/install-ms-odbc.sh + - name: Start database server + if: ${{ inputs.platform != 'sqlite:ci' }} + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }} + - uses: './.github/actions/ci-setup' + with: + php-version: ${{ matrix.php-version }} + php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 + extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} + - name: Create test database + if: ${{ inputs.platform == 'ms' }} + run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" + - name: Run tests + run: composer test:db:${{ inputs.platform }} + - name: Upload code coverage + uses: actions/upload-artifact@v3 + if: ${{ matrix.php-version == '8.1' && inputs.platform == 'sqlite:ci' }} + with: + name: coverage-db + path: | + build/coverage-db + build/coverage-db.cov diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml new file mode 100644 index 00000000..7a9dd751 --- /dev/null +++ b/.github/workflows/ci-mutation-tests.yml @@ -0,0 +1,45 @@ +name: Mutation tests + +on: + workflow_call: + inputs: + test-group: + type: string + required: true + description: One of unit, db, api or cli + +jobs: + mutation-tests: + runs-on: ubuntu-22.04 + strategy: + matrix: + php-version: [ '8.1' ] + steps: + - uses: actions/checkout@v3 + - uses: './.github/actions/ci-setup' + with: + php-version: ${{ matrix.php-version }} + php-extensions: openswoole-4.11.1 + extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} + - uses: actions/download-artifact@v3 + with: + name: coverage-${{ inputs.test-group }} + path: build + - name: Resolve infection args + id: infection_args + run: echo "::set-output name=args::--logger-github=false" +# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work +# run: | +# BRANCH="${GITHUB_REF#refs/heads/}" | +# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then +# echo "::set-output name=args::--logger-github=false" +# else +# echo "::set-output name=args::--logger-github=false --git-diff-lines --git-diff-base=develop" +# fi; + shell: bash + - if: ${{ inputs.test-group == 'unit' }} + run: composer infect:ci:unit -- ${{ steps.infection_args.outputs.args }} + env: + INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} + - if: ${{ inputs.test-group != 'unit' }} + run: composer infect:ci:${{ inputs.test-group }} -- ${{ steps.infection_args.outputs.args }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 00000000..b7f7af98 --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + workflow_call: + inputs: + test-group: + type: string + required: true + description: One of unit, api or cli + +jobs: + tests: + runs-on: ubuntu-22.04 + strategy: + matrix: + php-version: ['8.1'] + steps: + - uses: actions/checkout@v3 + - name: Start postgres database server + if: ${{ inputs.test-group == 'api' }} + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres + - name: Start maria database server + if: ${{ inputs.test-group == 'cli' }} + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria + - uses: './.github/actions/ci-setup' + with: + php-version: ${{ matrix.php-version }} + php-extensions: openswoole-4.11.1 + extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} + - run: composer test:${{ inputs.test-group }}:ci + - uses: actions/upload-artifact@v3 + if: ${{ matrix.php-version == '8.1' }} + with: + name: coverage-${{ inputs.test-group }} + path: | + build/coverage-${{ inputs.test-group }} + build/coverage-${{ inputs.test-group }}.cov diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1ce0d0b..f9d3660e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,146 +16,124 @@ jobs: php-version: ['8.1'] command: ['cs', 'stan', 'swagger:validate'] steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Use PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v3 + - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - tools: composer - extensions: openswoole-4.11.1 - coverage: none - - name: Install dependencies - run: composer install --no-interaction --prefer-dist + php-extensions: openswoole-4.11.1 + extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} - tests: + unit-tests: + uses: './.github/workflows/ci-tests.yml' + with: + test-group: unit + + cli-tests: + uses: './.github/workflows/ci-tests.yml' + with: + test-group: cli + + openswoole-api-tests: + uses: './.github/workflows/ci-tests.yml' + with: + test-group: api + + roadrunner-api-tests: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] - test-group: ['unit', 'api'] + php-version: [ '8.1' ] steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Start database server - if: ${{ matrix.test-group == 'api' }} - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - - name: Use PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v3 + - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres + - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.11.1 - coverage: pcov - ini-values: pcov.directory=module - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - run: composer test:${{ matrix.test-group }}:ci - - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.1' }} - with: - name: coverage-${{ matrix.test-group }} - path: | - build/coverage-${{ matrix.test-group }} - build/coverage-${{ matrix.test-group }}.cov + - run: composer install --no-interaction --prefer-dist + - run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr + - run: composer test:api:rr - db-tests: - runs-on: ubuntu-22.04 - strategy: - matrix: - php-version: ['8.1'] - platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] - env: - LC_ALL: C - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Install MSSQL ODBC - if: ${{ matrix.platform == 'ms' }} - run: sudo ./data/infra/ci/install-ms-odbc.sh - - name: Start database server - if: ${{ matrix.platform != 'sqlite:ci' }} - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ matrix.platform }} - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 - coverage: pcov - ini-values: pcov.directory=module - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - name: Create test database - if: ${{ matrix.platform == 'ms' }} - run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - - name: Run tests - run: composer test:db:${{ matrix.platform }} - - name: Upload code coverage - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.1' && matrix.platform == 'sqlite:ci' }} - with: - name: coverage-db - path: | - build/coverage-db - build/coverage-db.cov + sqlite-db-tests: + uses: './.github/workflows/ci-db-tests.yml' + with: + platform: 'sqlite:ci' - mutation-tests: + mysql-db-tests: + uses: './.github/workflows/ci-db-tests.yml' + with: + platform: 'mysql' + + maria-db-tests: + uses: './.github/workflows/ci-db-tests.yml' + with: + platform: 'maria' + + postgres-db-tests: + uses: './.github/workflows/ci-db-tests.yml' + with: + platform: 'postgres' + + ms-db-tests: + uses: './.github/workflows/ci-db-tests.yml' + with: + platform: 'ms' + + unit-mutation-tests: needs: - - tests - - db-tests - runs-on: ubuntu-22.04 - strategy: - matrix: - php-version: ['8.1'] - test-group: ['unit', 'db', 'api'] - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: openswoole-4.11.1 - coverage: pcov - ini-values: pcov.directory=module - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - uses: actions/download-artifact@v2 - with: - path: build - - if: ${{ matrix.test-group == 'unit' }} - run: composer infect:ci:unit - env: - INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} - - if: ${{ matrix.test-group != 'unit' }} - run: composer infect:ci:${{ matrix.test-group }} + - unit-tests + uses: './.github/workflows/ci-mutation-tests.yml' + with: + test-group: unit + + db-mutation-tests: + needs: + - sqlite-db-tests + uses: './.github/workflows/ci-mutation-tests.yml' + with: + test-group: db + + api-mutation-tests: + needs: + - openswoole-api-tests + uses: './.github/workflows/ci-mutation-tests.yml' + with: + test-group: api + + cli-mutation-tests: + needs: + - cli-tests + uses: './.github/workflows/ci-mutation-tests.yml' + with: + test-group: cli upload-coverage: needs: - - tests - - db-tests + - unit-tests + - openswoole-api-tests + - cli-tests + - sqlite-db-tests runs-on: ubuntu-22.04 strategy: matrix: php-version: ['8.1'] steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Use PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} coverage: pcov ini-values: pcov.directory=module - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v3 with: path: build - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov + - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - run: wget https://phar.phpunit.de/phpcov-8.2.1.phar - run: php phpcov-8.2.1.phar merge build --clover build/clover.xml - name: Publish coverage @@ -165,7 +143,10 @@ jobs: delete-artifacts: needs: - - mutation-tests + - unit-mutation-tests + - db-mutation-tests + - api-mutation-tests + - cli-mutation-tests - upload-coverage runs-on: ubuntu-22.04 steps: @@ -175,12 +156,13 @@ jobs: coverage-unit coverage-db coverage-api + coverage-cli build-docker-image: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 100 - uses: marceloprado/has-changed-path@v1 diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index fb24e60b..96457033 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -1,4 +1,4 @@ -name: Build docker image +name: Build and publish docker image on: push: @@ -8,21 +8,20 @@ on: - 'v*' jobs: - build: - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - with: - version: latest - - name: Login to docker hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build the image - run: bash ./docker/build + build-openswool: + uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main + secrets: inherit + with: + image-name: shlinkio/shlink + version-arg-name: SHLINK_VERSION + + build-roadrunner: + uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main + secrets: inherit + with: + image-name: shlinkio/shlink + version-arg-name: SHLINK_VERSION + platforms: 'linux/arm64/v8,linux/amd64' + tags-suffix: roadrunner + extra-build-args: | + SHLINK_RUNTIME=rr diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4903fe52..b4ed7bba 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -13,19 +13,18 @@ jobs: php-version: ['8.1'] swoole: ['yes', 'no'] steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Use PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v3 + - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - tools: composer - extensions: openswoole-4.11.1 + php-extensions: openswoole-4.11.1 + extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} + install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} - if: ${{ matrix.swoole == 'no' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} path: build @@ -34,9 +33,8 @@ jobs: needs: ['build'] runs-on: ubuntu-22.04 steps: - - name: Checkout code - uses: actions/checkout@v2 - - uses: actions/download-artifact@v2 + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v3 with: path: build - name: Publish release with assets diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 83864389..9002353d 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -12,20 +12,16 @@ jobs: matrix: php-version: ['8.1'] steps: - - name: Checkout code - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Determine version id: determine_version run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}" shell: bash - - name: Use PHP - uses: shivammathur/setup-php@v2 + - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - tools: composer - extensions: openswoole-4.11.1 - coverage: none - - run: composer install --no-interaction --prefer-dist + php-extensions: openswoole-4.11.1 + extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} - run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json diff --git a/.gitignore b/.gitignore index 933c25ee..daea5f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .idea +bin/.rr.* +bin/rr +config/roadrunner/.pid build !docker/build composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 71842087..d491db0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.3.0] - 2022-09-18 +### Added +* [#1221](https://github.com/shlinkio/shlink/issues/1221) Added experimental support to run Shlink with [RoadRunner](https://roadrunner.dev) instead of openswoole. +* [#1531](https://github.com/shlinkio/shlink/issues/1531) and [#1090](https://github.com/shlinkio/shlink/issues/1090) Added support for trailing slashes in short URLs. +* [#1406](https://github.com/shlinkio/shlink/issues/1406) Added new REST API version 3. + + When making requests to the REST API with `/rest/v3/...` and an error occurs, all error types will be different, with the next correlation: + + * `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data` + * `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion` + * `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found` + * `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation` + * `INVALID_URL` -> `https://shlink.io/api/error/invalid-url` + * `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug` + * `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found` + * `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict` + * `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found` + * `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured` + * `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication` + * `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key` + + If you make a request to the API with v2 or v1, the old error types will be returned, until Shlink 4 is released, when only the new ones will be used. + + Non-error responses are not affected. + +* [#1513](https://github.com/shlinkio/shlink/issues/1513) Added publishing of the docker image in GHCR. +* [#1114](https://github.com/shlinkio/shlink/issues/1114) Added support to provide an initial API key via `INITIAL_API_KEY` env var, when running Shlink with openswoole or RoadRunner. + + Also, the installer tool now allows to generate an initial API key that can be copy-pasted (this tool is run interactively), in case you use php-fpm or you don't want to use env vars. + +* [#1528](https://github.com/shlinkio/shlink/issues/1528) Added support to delay when the GeoLite2 DB file is downloaded in docker images, speeding up its startup time. + + In order to do it, pass `SKIP_INITIAL_GEOLITE_DOWNLOAD=true` when creating the container. + +### Changed +* [#1339](https://github.com/shlinkio/shlink/issues/1339) Added new test suite for CLI E2E tests. +* [#1503](https://github.com/shlinkio/shlink/issues/1503) Drastically improved build time in GitHub Actions, by optimizing parallelization and adding php extensions cache. +* [#1525](https://github.com/shlinkio/shlink/issues/1525) Migrated to custom doctrine CLI entry point. +* [#1492](https://github.com/shlinkio/shlink/issues/1492) Migrated to immutable options objects, mapped with [cuyz/valinor](https://github.com/CuyZ/Valinor). + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.2.1] - 2022-08-08 ### Added * *Nothing* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2024adca..feb437ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,9 @@ In order to ensure stability and no regressions are introduced while developing Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files. -* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line* +* **CLI tests**: These are E2E tests too, but they test console commands instead of REST endpoints. + + They use Maria DB as the database engine, and include the same fixtures as the API tests, that ensure the same data exists at the beginning of the execution. Depending on the kind of contribution, maybe not all kinds of tests are needed, but the more you provide, the better. @@ -119,9 +121,9 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, For example, `test:db:postgres`. * Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. +* Run `./indocker composer test:cli` to run CLI E2E tests. For these, the Maria DB database engine is used. * Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). -* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration. -* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible. +* Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible. ## Pull request process @@ -133,7 +135,7 @@ Once everything is clear, to provide a pull request to this project, you should The base branch should always be `develop`, and the target branch for the pull request should also be `develop`. -Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created. +Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created. ## Architectural Decision Records diff --git a/Dockerfile b/Dockerfile index 2944db45..2835d75f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM php:8.1.9-alpine3.16 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} +ARG SHLINK_RUNTIME=openswoole +ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ENV OPENSWOOLE_VERSION 4.11.1 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 @@ -22,8 +24,10 @@ RUN \ # Install openswoole and sqlsrv driver for x86_64 builds RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ - pecl install openswoole-${OPENSWOOLE_VERSION} && \ - docker-php-ext-enable openswoole && \ + if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ + pecl install openswoole-${OPENSWOOLE_VERSION} && \ + docker-php-ext-enable openswoole ; \ + fi; \ if [ $(uname -m) == "x86_64" ]; then \ wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ @@ -38,7 +42,12 @@ FROM base as builder COPY . . COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ - php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \ + php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ + if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ + php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interactionc ; \ + elif [ $SHLINK_RUNTIME == 'rr' ]; then \ + php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \ + fi; \ php composer.phar clear-cache && \ rm -r docker composer.* && \ sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php @@ -49,9 +58,12 @@ FROM base LABEL maintainer="Alejandro Celaya " COPY --from=builder /etc/shlink . -RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink +RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \ + if [ "$SHLINK_RUNTIME" == 'rr' ]; then \ + php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; \ + fi; -# Expose default openswoole port +# Expose default port EXPOSE 8080 # Copy config specific for the image diff --git a/README.md b/README.md index 1fe3b89c..bb99634e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u - [Full documentation](#full-documentation) - [Docker image](#docker-image) -- [Self hosted](#self-hosted) +- [Self-hosted](#self-hosted) - [Download](#download) - [Configure](#configure) - [Using shlink](#using-shlink) diff --git a/bin/doctrine b/bin/doctrine new file mode 100755 index 00000000..4fec1714 --- /dev/null +++ b/bin/doctrine @@ -0,0 +1,12 @@ +#!/usr/bin/env php +get(Application::class); + $worker = $container->get(PSR7Worker::class); + + while ($req = $worker->waitRequest()) { + try { + $worker->respond($app->handle($req)); + } catch (Throwable $e) { + $worker->getWorker()->error((string) $e); + } + } + } else { + $container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks(); + } +})(); diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 3f6e27e6..1cbf948a 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -1,25 +1,38 @@ #!/usr/bin/env sh + export APP_ENV=test -export DB_DRIVER=postgres export TEST_ENV=api -export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"} +export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" +export DB_DRIVER="${DB_DRIVER:-"postgres"}" +export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" # Reset logs +OUTPUT_LOGS=data/log/api-tests/output.log rm -rf data/log/api-tests mkdir data/log/api-tests -touch data/log/api-tests/output.log +touch $OUTPUT_LOGS # Try to stop server just in case it hanged in last execution -vendor/bin/laminas mezzio:swoole:stop +[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f echo 'Starting server...' -vendor/bin/laminas mezzio:swoole:start -d -sleep 2 +[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \ + -o=http.address=0.0.0.0:9999 \ + -o=logs.encoding=json \ + -o=logs.channels.http.encoding=json \ + -o=logs.channels.server.encoding=json \ + -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ + -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ + -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & +sleep 2 # Let's give the server a couple of seconds to start vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* testsExitCode=$? -vendor/bin/laminas mezzio:swoole:stop +[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999 # Exit this script with the same code as the tests. If tests failed, this script has to fail exit $testsExitCode diff --git a/build.sh b/build.sh index e274210a..d9cda64d 100755 --- a/build.sh +++ b/build.sh @@ -24,6 +24,7 @@ rsync -av * "${builtContent}" \ --exclude=*docker* \ --exclude=Dockerfile \ --include=.htaccess \ + --include=config/roadrunner/.rr.yml \ --exclude-from=./.dockerignore cd "${builtContent}" @@ -36,6 +37,9 @@ ${composerBin} install --no-dev --prefer-dist $composerFlags if [[ $noSwoole ]]; then # If generating a dist not for openswoole, uninstall mezzio-swoole ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags +else + # If generating a dist for openswoole, uninstall RoadRunner + ${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev $composerFlags fi # Delete development files diff --git a/composer.json b/composer.json index 3cba02fe..8be0e37a 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,8 @@ ], "require": { "php": "^8.1", + "ext-curl": "*", + "ext-gd": "*", "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", @@ -43,12 +45,14 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.3", - "shlinkio/shlink-common": "^5.0", - "shlinkio/shlink-config": "^2.0", - "shlinkio/shlink-event-dispatcher": "^2.5", + "shlinkio/shlink-common": "^5.1", + "shlinkio/shlink-config": "^2.1", + "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^4.0", - "shlinkio/shlink-installer": "^8.1", - "shlinkio/shlink-ip-geolocation": "^3.0", + "shlinkio/shlink-installer": "^8.2", + "shlinkio/shlink-ip-geolocation": "^3.1", + "spiral/roadrunner": "^2.11", + "spiral/roadrunner-jobs": "^2.3", "symfony/console": "^6.1", "symfony/filesystem": "^6.1", "symfony/lock": "^6.1", @@ -58,8 +62,8 @@ "require-dev": { "cebe/php-openapi": "^1.7", "devster/ubench": "^2.1", - "dms/phpunit-arraysubset-asserts": "^0.3.0", - "infection/infection": "^0.26.5", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "infection/infection": "^0.26.15", "openswoole/ide-helper": "~4.11.1", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.8", @@ -69,7 +73,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.1.0", + "shlinkio/shlink-test-utils": "^3.3", "symfony/var-dumper": "^6.1", "veewee/composer-run-parallel": "^1.1" }, @@ -87,8 +91,10 @@ "autoload-dev": { "psr-4": { "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", + "ShlinkioCliTest\\Shlink\\CLI\\": "module/CLI/test-cli", "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", + "ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db", "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db" }, @@ -98,31 +104,18 @@ }, "scripts": { "ci": [ - "@cs", - "@stan", - "@swagger:validate", - "@test:ci", - "@infect:ci" - ], - "ci:parallel": [ "@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", - "@parallel infect:test:api infect:ci:unit infect:ci:db" + "@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db" ], "cs": "phpcs", "cs:fix": "phpcbf", "stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8", "test": [ - "@test:unit", - "@test:db", - "@test:api" + "@parallel test:unit test:db", + "@parallel test:api test:cli" ], - "test:ci": [ - "@test:unit:ci", - "@test:db", - "@test:api:ci" - ], - "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", - "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", + "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox", + "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", "test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html", "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", @@ -132,12 +125,18 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", + "test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", - "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests", + "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api", + "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", + "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", + "test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli", + "infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json", - "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api", + "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=80 --configuration=infection-cli.json", + "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", "infect:test": [ "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", "@infect:ci" @@ -150,18 +149,20 @@ "@test:api:ci", "@infect:ci:api" ], + "infect:test:cli": [ + "@test:cli:ci", + "@infect:ci:cli" + ], "swagger:validate": "php-openapi validate docs/swagger/swagger.json", "swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json", "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php" }, "scripts-descriptions": { "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"", - "ci:parallel": "Same as \"ci\", but parallelizing tasks as much as possible", "cs": "Checks coding styles", "cs:fix": "Fixes coding styles, when possible", "stan": "Inspects code with phpstan", "test": "Runs all test suites", - "test:ci": "Runs all test suites, generating all needed reports and logs for CI envs", "test:unit": "Runs unit test suites", "test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs", "test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report", @@ -173,7 +174,11 @@ "test:db:postgres": "Runs database test suites on a PostgreSQL database", "test:db:ms": "Runs database test suites on a Microsoft SQL Server database", "test:api": "Runs API test suites", - "test:api:ci": "Runs API test suites, and generates code coverage reports", + "test:api:ci": "Runs API test suites, and generates code coverage for CI", + "test:api:pretty": "Runs API test suites, and generates code coverage in HTML format", + "test:cli": "Runs CLI test suites", + "test:cli:ci": "Runs CLI test suites, and generates code coverage for CI", + "test:cli:pretty": "Runs CLI test suites, and generates code coverage in HTML format", "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs", "infect:ci:unit": "Checks unit tests quality applying mutation testing with existing reports and logs", "infect:ci:db": "Checks db tests quality applying mutation testing with existing reports and logs", diff --git a/config/autoload/common.global.php b/config/autoload/common.global.php index b35807fd..19404d8c 100644 --- a/config/autoload/common.global.php +++ b/config/autoload/common.global.php @@ -8,8 +8,8 @@ return [ 'debug' => false, - // Disabling config cache for cli, ensures it's never used for openswoole and also that console commands don't - // generate a cache file that's then used by non-openswoole web executions + // Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console + // commands don't generate a cache file that's then used by php-fpm web executions ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli', ]; diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index dbc553f1..657caffb 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -3,12 +3,22 @@ declare(strict_types=1); use GuzzleHttp\Client; +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Mezzio\Container; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\ServerRequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\UploadedFileFactoryInterface; +use Spiral\RoadRunner\Http\PSR7Worker; +use Spiral\RoadRunner\WorkerInterface; return [ 'dependencies' => [ + 'factories' => [ + PSR7Worker::class => ConfigAbstractFactory::class, + ], + 'delegators' => [ Mezzio\Application::class => [ Container\ApplicationConfigInjectionDelegator::class, @@ -26,4 +36,13 @@ return [ ], ], + ConfigAbstractFactory::class => [ + PSR7Worker::class => [ + WorkerInterface::class, + ServerRequestFactoryInterface::class, + StreamFactoryInterface::class, + UploadedFileFactoryInterface::class, + ], + ], + ]; diff --git a/config/autoload/error-handler.global.php b/config/autoload/error-handler.global.php index b4872bfe..65e5b616 100644 --- a/config/autoload/error-handler.global.php +++ b/config/autoload/error-handler.global.php @@ -6,12 +6,14 @@ use Laminas\Stratigility\Middleware\ErrorHandler; use Mezzio\ProblemDetails\ProblemDetailsMiddleware; use Shlinkio\Shlink\Common\Logger; +use function Shlinkio\Shlink\Core\toProblemDetailsType; + return [ 'problem-details' => [ 'default_types_map' => [ - 404 => 'NOT_FOUND', - 500 => 'INTERNAL_SERVER_ERROR', + 404 => toProblemDetailsType('not-found'), + 500 => toProblemDetailsType('internal-server-error'), ], ], diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 2e120e35..fbc5fa03 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -44,6 +44,7 @@ return [ Option\UrlShortener\AutoResolveTitlesConfigOption::class, Option\UrlShortener\AppendExtraPathConfigOption::class, Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, + Option\UrlShortener\EnableTrailingSlashConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, @@ -72,9 +73,18 @@ return [ InstallationCommand::DB_MIGRATE->value => [ 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME, ], + InstallationCommand::ORM_PROXIES->value => [ + 'command' => 'bin/doctrine orm:generate-proxies', + ], + InstallationCommand::ORM_CLEAR_CACHE->value => [ + 'command' => 'bin/doctrine orm:clear-cache:metadata', + ], InstallationCommand::GEOLITE_DOWNLOAD_DB->value => [ 'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME, ], + InstallationCommand::API_KEY_GENERATE->value => [ + 'command' => 'bin/cli ' . Command\Api\GenerateKeyCommand::NAME, + ], ], ], diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index b10ad86e..e818404b 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -7,7 +7,7 @@ return [ 'mercure' => [ 'public_hub_url' => 'http://localhost:8001', 'internal_hub_url' => 'http://shlink_mercure_proxy', - 'jwt_secret' => 'mercure_jwt_key', + 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', ], ]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index c628c4fd..25db6b7b 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -45,6 +45,7 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ + Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class, Router\Middleware\ImplicitOptionsMiddleware::class, Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 298b9349..b36c4b78 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -7,15 +7,19 @@ namespace Shlinkio\Shlink; use Fig\Http\Message\RequestMethodInterface; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Core\Action as CoreAction; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Rest\Action; use Shlinkio\Shlink\Rest\ConfigProvider; use Shlinkio\Shlink\Rest\Middleware; use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; +use function sprintf; + return (static function (): array { $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; + $shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : ''; return [ @@ -90,7 +94,7 @@ return (static function (): array { ], [ 'name' => CoreAction\RedirectAction::class, - 'path' => '/{shortCode}', + 'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix), 'middleware' => [ IpAddress::class, CoreAction\RedirectAction::class, diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index 0637301a..4d7a6e9a 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -4,33 +4,40 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; -return [ +return (static function (): array { + /** @var string|null $disableTrackingFrom */ + $disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(); - 'tracking' => [ - // Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations - // This applies only if IP address tracking is enabled - 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true), + return [ - // Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence - 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true), + 'tracking' => [ + // Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations + // This applies only if IP address tracking is enabled + 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true), - // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence - 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(), + // Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence + 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true), - // If true, visits will not be tracked at all - 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false), + // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence + 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(), - // If true, visits will be tracked, but neither the IP address, nor the location will be resolved - 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false), + // If true, visits will not be tracked at all + 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false), - // If true, the referrer will not be tracked - 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false), + // If true, visits will be tracked, but neither the IP address, nor the location will be resolved + 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false), - // If true, the user agent will not be tracked - 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false), + // If true, the referrer will not be tracked + 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false), - // A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default - 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(), - ], + // If true, the user agent will not be tracked + 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false), -]; + // A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default + 'disable_tracking_from' => $disableTrackingFrom === null + ? [] + : array_map(trim(...), explode(',', $disableTrackingFrom)), + ], + + ]; +})(); diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 0069ffa9..f49570e1 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,14 +2,19 @@ declare(strict_types=1); -$isSwoole = extension_loaded('openswoole'); +use function Shlinkio\Shlink\Config\runningInOpenswoole; +use function Shlinkio\Shlink\Config\runningInRoadRunner; return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'), + 'hostname' => sprintf('localhost:%s', match (true) { + runningInRoadRunner() => '8800', + runningInOpenswoole() => '8080', + default => '8000', + }), ], 'auto_resolve_titles' => true, // 'multi_segment_slugs_enabled' => true, diff --git a/config/config.php b/config/config.php index 6c38707d..15a45348 100644 --- a/config/config.php +++ b/config/config.php @@ -13,11 +13,13 @@ use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider; use function class_exists; use function Shlinkio\Shlink\Config\env; +use function Shlinkio\Shlink\Config\openswooleIsInstalled; +use function Shlinkio\Shlink\Config\runningInRoadRunner; use const PHP_SAPI; -$isCli = PHP_SAPI === 'cli'; $isTestEnv = env('APP_ENV') === 'test'; +$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner(); return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv @@ -26,7 +28,7 @@ return (new ConfigAggregator\ConfigAggregator([ Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - $isCli && class_exists(Swoole\ConfigProvider::class) + $enableSwoole && class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]), ProblemDetails\ConfigProvider::class, diff --git a/config/roadrunner/.rr.dev.yml b/config/roadrunner/.rr.dev.yml new file mode 100644 index 00000000..cc0bbf29 --- /dev/null +++ b/config/roadrunner/.rr.dev.yml @@ -0,0 +1,49 @@ +version: '2.7' + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: 'php ../../bin/roadrunner-worker.php' + +http: + address: '0.0.0.0:8080' + middleware: ['static'] + static: + dir: '../../public' + forbid: ['.php', '.htaccess'] + pool: + num_workers: 1 + +jobs: + pool: + num_workers: 1 + timeout: 300 + consume: ['shlink'] + pipelines: + shlink: + driver: memory + config: + priority: 10 + prefetch: 10 + +logs: + mode: development + channels: + http: + level: debug + server: + level: debug + metrics: + level: debug + +reload: + interval: 1s + patterns: ['.php'] + services: + http: + dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor'] + recursive: true + jobs: + dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor'] + recursive: true diff --git a/config/roadrunner/.rr.yml b/config/roadrunner/.rr.yml new file mode 100644 index 00000000..d44801ee --- /dev/null +++ b/config/roadrunner/.rr.yml @@ -0,0 +1,36 @@ +version: '2.7' + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php' + +http: + address: '0.0.0.0:${PORT}' + middleware: ['static'] + static: + dir: '../../public' + forbid: ['.php', '.htaccess'] + pool: + num_workers: ${WEB_WORKER_NUM} + +jobs: + timeout: 300 # 5 minutes + pool: + num_workers: ${TASK_WORKER_NUM} + consume: ['shlink'] + pipelines: + shlink: + driver: memory + config: + priority: 10 + prefetch: 10 + +logs: + mode: production + channels: + http: + level: info # Log all http requests, set to info to disable + server: + level: debug # Everything written to worker stderr is logged diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index 52c9d4fb..2653b552 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -10,8 +10,8 @@ use Psr\Container\ContainerInterface; use function register_shutdown_function; use function sprintf; -use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST; -use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT; +use const ShlinkioTest\Shlink\API_TESTS_HOST; +use const ShlinkioTest\Shlink\API_TESTS_PORT; /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; @@ -24,10 +24,15 @@ $httpClient = $container->get('shlink_test_api_client'); register_shutdown_function(function () use ($httpClient): void { $httpClient->request( 'GET', - sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT), + sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT), ); }); -$testHelper->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']); +$testHelper->createTestDb( + ['bin/cli', 'db:create'], + ['bin/cli', 'db:migrate'], + ['bin/doctrine', 'orm:schema-tool:drop'], + ['bin/doctrine', 'dbal:run-sql'], +); ApiTest\ApiTestCase::setApiClient($httpClient); ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? [])); diff --git a/config/test/bootstrap_cli_tests.php b/config/test/bootstrap_cli_tests.php new file mode 100644 index 00000000..c8c33721 --- /dev/null +++ b/config/test/bootstrap_cli_tests.php @@ -0,0 +1,33 @@ +get(Helper\TestHelper::class); +$config = $container->get('config'); +$em = $container->get(EntityManager::class); + +// Delete old coverage in PHP, to avoid merging older executions with current one +$covFile = __DIR__ . '/../../build/coverage-cli.cov'; +if (file_exists($covFile)) { + unlink($covFile); +} + +$testHelper->createTestDb( + ['bin/cli', 'db:create'], + ['bin/cli', 'db:migrate'], + ['bin/doctrine', 'orm:schema-tool:drop'], + ['bin/doctrine', 'dbal:run-sql'], +); +CliTest\CliTestCase::setSeedFixturesCallback( + static fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []), +); diff --git a/config/test/bootstrap_db_tests.php b/config/test/bootstrap_db_tests.php index 0237d741..5aa8ea51 100644 --- a/config/test/bootstrap_db_tests.php +++ b/config/test/bootstrap_db_tests.php @@ -8,5 +8,10 @@ use Psr\Container\ContainerInterface; /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; -$container->get(Helper\TestHelper::class)->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']); +$container->get(Helper\TestHelper::class)->createTestDb( + ['bin/cli', 'db:create'], + ['bin/cli', 'db:migrate'], + ['bin/doctrine', 'orm:schema-tool:drop'], + ['bin/doctrine', 'dbal:run-sql'], +); DbTest\DatabaseTestCase::setEntityManager($container->get('em')); diff --git a/config/test/constants.php b/config/test/constants.php index a2c880fc..c767abc9 100644 --- a/config/test/constants.php +++ b/config/test/constants.php @@ -4,5 +4,5 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink; -const SWOOLE_TESTING_HOST = '127.0.0.1'; -const SWOOLE_TESTING_PORT = 9999; +const API_TESTS_HOST = '127.0.0.1'; +const API_TESTS_PORT = 9999; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index b9bac12d..678e1b05 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -8,8 +8,10 @@ use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; +use League\Event\EventDispatcher; use Monolog\Level; use PHPUnit\Runner\Version; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -20,24 +22,60 @@ use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html; use SebastianBergmann\CodeCoverage\Report\PHP; use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml; use Shlinkio\Shlink\Common\Logger\LoggerType; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleTerminateEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use function file_exists; +use function Functional\contains; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; use function sprintf; use function sys_get_temp_dir; -use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST; -use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT; +use const ShlinkioTest\Shlink\API_TESTS_HOST; +use const ShlinkioTest\Shlink\API_TESTS_PORT; $isApiTest = env('TEST_ENV') === 'api'; -$generateCoverage = env('GENERATE_COVERAGE') === 'yes'; -if ($isApiTest && $generateCoverage) { +$isCliTest = env('TEST_ENV') === 'cli'; +$isE2eTest = $isApiTest || $isCliTest; +$coverageType = env('GENERATE_COVERAGE'); +$generateCoverage = contains(['yes', 'pretty'], $coverageType); + +$coverage = null; +if ($isE2eTest && $generateCoverage) { $filter = new Filter(); $filter->includeDirectory(__DIR__ . '/../../module/Core/src'); - $filter->includeDirectory(__DIR__ . '/../../module/Rest/src'); + $filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src'); $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); } +/** + * @param 'api'|'cli' $type + */ +$exportCoverage = static function (string $type = 'api') use (&$coverage, $coverageType): void { + if ($coverage === null) { + return; + } + + $basePath = __DIR__ . '/../../build/coverage-' . $type; + $covPath = $basePath . '.cov'; + + // Every CLI test runs on its own process and dumps the coverage afterwards. + // Try to load it and merge it, so that we end up with the whole coverage at the end. + if ($type === 'cli' && file_exists($covPath)) { + $coverage->merge(require $covPath); + } + + if ($coverageType === 'pretty') { + (new Html())->process($coverage, $basePath . '/coverage-html'); + } else { + (new PHP())->process($coverage, $covPath); + (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); + } +}; + $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('CI', false); @@ -92,14 +130,13 @@ return [ 'schema' => 'http', 'hostname' => 'doma.in', ], - 'validate_url' => true, ], 'mezzio-swoole' => [ 'enable_coroutine' => false, 'swoole-http-server' => [ - 'host' => SWOOLE_TESTING_HOST, - 'port' => SWOOLE_TESTING_PORT, + 'host' => API_TESTS_HOST, + 'port' => API_TESTS_PORT, 'process-name' => 'shlink_test', 'options' => [ 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', @@ -113,17 +150,10 @@ return [ [ 'name' => 'dump_coverage', 'path' => '/api-tests/stop-coverage', - 'middleware' => middleware(static function () use (&$coverage) { + 'middleware' => middleware(static function () use ($exportCoverage) { // TODO I have tried moving this block to a listener so that it's invoked automatically, // but then the coverage is generated empty ¯\_(ツ)_/¯ - if ($coverage) { // @phpstan-ignore-line - $basePath = __DIR__ . '/../../build/coverage-api'; - - (new PHP())->process($coverage, $basePath . '.cov'); - (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); - (new Html())->process($coverage, $basePath . '/coverage-html'); - } - + $exportCoverage(); return new EmptyResponse(); }), 'allowed_methods' => ['GET'], @@ -157,13 +187,69 @@ return [ 'dependencies' => [ 'services' => [ 'shlink_test_api_client' => new Client([ - 'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT), + 'base_uri' => sprintf('http://%s:%s/', API_TESTS_HOST, API_TESTS_PORT), 'http_errors' => false, ]), ], 'factories' => [ TestUtils\Helper\TestHelper::class => InvokableFactory::class, ], + 'delegators' => $isCliTest ? [ + Application::class => [ + static function ( + ContainerInterface $c, + string $serviceName, + callable $callback, + ) use ( + &$coverage, + $exportCoverage, + ) { + /** @var Application $app */ + $app = $callback(); + $wrappedEventDispatcher = new EventDispatcher(); + + // When the command starts, start collecting coverage + $wrappedEventDispatcher->subscribeTo( + ConsoleCommandEvent::class, + static function () use (&$coverage): void { + $id = env('COVERAGE_ID'); + if ($id === null) { + return; + } + + $coverage?->start($id); + }, + ); + // When the command ends, stop collecting coverage + $wrappedEventDispatcher->subscribeTo( + ConsoleTerminateEvent::class, + static function () use (&$coverage, $exportCoverage): void { + $id = env('COVERAGE_ID'); + if ($id === null) { + return; + } + + $coverage?->stop(); + $exportCoverage('cli'); + }, + ); + + $app->setDispatcher(new class ($wrappedEventDispatcher) implements EventDispatcherInterface { + public function __construct(private EventDispatcher $wrappedDispatcher) + { + } + + public function dispatch(object $event, ?string $eventName = null): object + { + $this->wrappedDispatcher->dispatch($event); + return $event; + } + }); + + return $app; + }, + ], + ] : [], ], 'entity_manager' => [ @@ -172,6 +258,7 @@ return [ 'data_fixtures' => [ 'paths' => [ + // TODO These are used for CLI tests too, so maybe should be somewhere else __DIR__ . '/../../module/Rest/test-api/Fixtures', ], ], diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile new file mode 100644 index 00000000..8520b92d --- /dev/null +++ b/data/infra/roadrunner.Dockerfile @@ -0,0 +1,73 @@ +FROM php:8.1.9-alpine3.16 +MAINTAINER Alejandro Celaya + +ENV APCU_VERSION 5.1.21 +ENV PDO_SQLSRV_VERSION 5.10.1 +ENV MS_ODBC_SQL_VERSION 17.5.2.2 + +RUN apk update + +# Install common php extensions +RUN docker-php-ext-install pdo_mysql +RUN docker-php-ext-install calendar + +RUN apk add --no-cache oniguruma-dev +RUN docker-php-ext-install mbstring + +RUN apk add --no-cache sqlite-libs +RUN apk add --no-cache sqlite-dev +RUN docker-php-ext-install pdo_sqlite + +RUN apk add --no-cache icu-dev +RUN docker-php-ext-install intl + +RUN apk add --no-cache libzip-dev zlib-dev +RUN docker-php-ext-install zip + +RUN apk add --no-cache libpng-dev +RUN docker-php-ext-install gd + +RUN apk add --no-cache postgresql-dev +RUN docker-php-ext-install pdo_pgsql + +RUN docker-php-ext-install sockets +RUN docker-php-ext-install bcmath + +# Install APCu extension +ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz +RUN mkdir -p /usr/src/php/ext/apcu \ + && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ + && docker-php-ext-configure apcu \ + && docker-php-ext-install apcu \ + && rm /tmp/apcu.tar.gz \ + && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ + && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini + +# Install pcov and sqlsrv driver +RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ + docker-php-ext-enable pdo_sqlsrv pcov && \ + apk del .phpize-deps && \ + rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk + +# Install composer +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# Make home directory writable by anyone +RUN chmod 777 /home + +VOLUME /home/shlink +WORKDIR /home/shlink + +# Expose roadrunner port +EXPOSE 8080 + +CMD \ + # Install dependencies if the vendor dir does not exist + if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \ + # Download roadrunner binary + if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; fi && \ + # This forces the app to be started every second until the exit code is 0 + until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist index 990d1b5d..1c5409c6 100644 --- a/docker-compose.override.yml.dist +++ b/docker-compose.override.yml.dist @@ -13,6 +13,12 @@ services: - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro + shlink_roadrunner: + user: 1000:1000 + volumes: + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro + shlink_db_mysql: user: 1000:1000 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 739c0079..8293ab03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,6 +73,30 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' + shlink_roadrunner: + container_name: shlink_roadrunner + build: + context: . + dockerfile: ./data/infra/roadrunner.Dockerfile + ports: + - "8800:8080" + volumes: + - ./:/home/shlink + - ./data/infra/php.ini:/usr/local/etc/php/php.ini + links: + - shlink_db_mysql + - shlink_db_postgres + - shlink_db_maria + - shlink_db_ms + - shlink_redis + - shlink_mercure + - shlink_mercure_proxy + - shlink_rabbitmq + environment: + LC_ALL: C + extra_hosts: + - 'host.docker.internal:host-gateway' + shlink_db_mysql: container_name: shlink_db_mysql image: mysql:5.7 @@ -144,8 +168,8 @@ services: - "3080:80" environment: SERVER_NAME: ":80" - MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key - MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key + MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error + MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000" shlink_rabbitmq: diff --git a/docker/build b/docker/build deleted file mode 100755 index fdd58106..00000000 --- a/docker/build +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -set -ex - -PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64" -DOCKER_IMAGE="shlinkio/shlink" - -# If ref is not develop, then this is a tag. Build that docker tag and also "stable" -if [[ "$GITHUB_REF" != *"develop"* ]]; then - VERSION=${GITHUB_REF#refs/tags/v} - TAGS="-t ${DOCKER_IMAGE}:${VERSION}" - # Push stable tag only if this is not an alpha or beta tag - [[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable" - - docker buildx build --push \ - --build-arg SHLINK_VERSION=${VERSION} \ - --platform ${PLATFORMS} \ - ${TAGS} . - -# If build branch is develop, build latest -elif [[ "$GITHUB_REF" == *"develop"* ]]; then - docker buildx build --push \ - --platform ${PLATFORMS} \ - -t ${DOCKER_IMAGE}:latest . -fi diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 4fba24b6..9dc99351 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -6,11 +6,14 @@ namespace Shlinkio\Shlink; use Shlinkio\Shlink\Common\Logger\LoggerType; +use function Shlinkio\Shlink\Config\runningInRoadRunner; + return [ 'logger' => [ 'Shlink' => [ 'type' => LoggerType::STREAM->value, + 'destination' => runningInRoadRunner() ? 'php://stderr' : 'php://stdout', ], ], diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index f1c4c495..fb8b7bf2 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -13,24 +13,36 @@ echo "Updating database..." php bin/cli db:migrate -n ${flags} echo "Generating proxies..." -php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n ${flags} +php bin/doctrine orm:generate-proxies -n ${flags} echo "Clearing entities cache..." -php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n ${flags} +php bin/doctrine orm:clear-cache:metadata -n ${flags} -# Try to download GeoLite2 db file only if the license key env var was defined -if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then +# Try to download GeoLite2 db file only if the license key env var was defined and skipping was not explicitly set +if [ ! -z "${GEOLITE_LICENSE_KEY}" ] && [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" != "true" ]; then echo "Downloading GeoLite2 db file..." php bin/cli visit:download-db -n ${flags} fi # Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided -if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then +if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ]; then echo "Configuring periodic visit location..." echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root /usr/sbin/crond & fi -# When restarting the container, openswoole might think it is already in execution -# This forces the app to be started every second until the exit code is 0 -until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done +# RoadRunner config needs these to have been set, so falling back to default values if not set yet +if [ "$SHLINK_RUNTIME" == 'rr' ]; then + export PORT="${PORT:-"8080"}" + # Default to 0 so that RoadRunner decides the number of workers based on the amount of logical CPUs + export WEB_WORKER_NUM="${WEB_WORKER_NUM:-"0"}" + export TASK_WORKER_NUM="${TASK_WORKER_NUM:-"0"}" +fi + +if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then + # When restarting the container, openswoole might think it is already in execution + # This forces the app to be started every second until the exit code is 0 + until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done +elif [ "$SHLINK_RUNTIME" == 'rr' ]; then + ./bin/rr serve -c config/roadrunner/.rr.yml +fi diff --git a/docs/swagger/examples/short-url-invalid-args.json b/docs/swagger/examples/short-url-invalid-args-v2.json similarity index 100% rename from docs/swagger/examples/short-url-invalid-args.json rename to docs/swagger/examples/short-url-invalid-args-v2.json diff --git a/docs/swagger/examples/short-url-invalid-args-v3.json b/docs/swagger/examples/short-url-invalid-args-v3.json new file mode 100644 index 00000000..3e9171c6 --- /dev/null +++ b/docs/swagger/examples/short-url-invalid-args-v3.json @@ -0,0 +1,9 @@ +{ + "value": { + "title": "Invalid data", + "type": "https://shlink.io/api/error/invalid-data", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["maxVisits", "validSince"] + } +} diff --git a/docs/swagger/examples/short-url-not-found.json b/docs/swagger/examples/short-url-not-found-v2.json similarity index 100% rename from docs/swagger/examples/short-url-not-found.json rename to docs/swagger/examples/short-url-not-found-v2.json diff --git a/docs/swagger/examples/short-url-not-found-v3.json b/docs/swagger/examples/short-url-not-found-v3.json new file mode 100644 index 00000000..82f3469c --- /dev/null +++ b/docs/swagger/examples/short-url-not-found-v3.json @@ -0,0 +1,9 @@ +{ + "value": { + "detail": "No URL found with short code \"abc123\"", + "title": "Short URL not found", + "type": "https://shlink.io/api/error/short-url-not-found", + "status": 404, + "shortCode": "abc123" + } +} diff --git a/docs/swagger/examples/tag-not-found.json b/docs/swagger/examples/tag-not-found-v2.json similarity index 100% rename from docs/swagger/examples/tag-not-found.json rename to docs/swagger/examples/tag-not-found-v2.json diff --git a/docs/swagger/examples/tag-not-found-v3.json b/docs/swagger/examples/tag-not-found-v3.json new file mode 100644 index 00000000..62beb42c --- /dev/null +++ b/docs/swagger/examples/tag-not-found-v3.json @@ -0,0 +1,9 @@ +{ + "value": { + "detail": "Tag with name \"foo\" could not be found", + "title": "Tag not found", + "type": "https://shlink.io/api/error/tag-not-found", + "status": 404, + "tag": "foo" + } +} diff --git a/docs/swagger/parameters/version.json b/docs/swagger/parameters/version.json index c2b1cc1a..abb7e0f7 100644 --- a/docs/swagger/parameters/version.json +++ b/docs/swagger/parameters/version.json @@ -6,6 +6,7 @@ "schema": { "type": "string", "enum": [ + "3", "2", "1" ] diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 6e8bb015..2675ab61 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -327,11 +327,11 @@ }, "url": { "type": "string", - "description": "A URL that could not be verified, if the error type is INVALID_URL" + "description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url" }, "customSlug": { "type": "string", - "description": "Provided custom slug when the error type is INVALID_SLUG" + "description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug" }, "domain": { "type": "string", @@ -342,10 +342,31 @@ ] }, "examples": { - "Invalid arguments": { - "$ref": "../examples/short-url-invalid-args.json" + "Invalid arguments with API v3 and newer": { + "$ref": "../examples/short-url-invalid-args-v3.json" }, - "Invalid long URL": { + "Invalid long URL with API v3 and newer": { + "value": { + "title": "Invalid URL", + "type": "https://shlink.io/api/error/invalid-url", + "detail": "Provided URL foo is invalid. Try with a different one.", + "status": 400, + "url": "https://invalid-url.com" + } + }, + "Non-unique slug with API v3 and newer": { + "value": { + "title": "Invalid custom slug", + "type": "https://shlink.io/api/error/non-unique-slug", + "detail": "Provided slug \"my-slug\" is already in use.", + "status": 400, + "customSlug": "my-slug" + } + }, + "Invalid arguments previous to API v3": { + "$ref": "../examples/short-url-invalid-args-v2.json" + }, + "Invalid long URL previous to API v3": { "value": { "title": "Invalid URL", "type": "INVALID_URL", @@ -354,7 +375,7 @@ "url": "https://invalid-url.com" } }, - "Non-unique slug": { + "Non-unique slug previous to API v3": { "value": { "title": "Invalid custom slug", "type": "INVALID_SLUG", diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 722476bb..aa26fa1b 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -85,19 +85,39 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "title": "Invalid URL", - "type": "INVALID_URL", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" + "examples": { + "API v3 and newer": { + "value": { + "title": "Invalid URL", + "type": "https://shlink.io/api/error/invalid-url", + "detail": "Provided URL foo is invalid. Try with a different one.", + "status": 400, + "url": "https://invalid-url.com" + } + }, + "Previous to API v3": { + "value": { + "title": "Invalid URL", + "type": "INVALID_URL", + "detail": "Provided URL foo is invalid. Try with a different one.", + "status": 400, + "url": "https://invalid-url.com" + } + } } }, "text/plain": { "schema": { "type": "string" }, - "example": "INVALID_URL" + "examples": { + "API v3 and newer": { + "value": "https://shlink.io/api/error/invalid-url" + }, + "Previous to API v3": { + "value": "INVALID_URL" + } + } } } }, diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 9065ff89..1b001cc9 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -83,8 +83,11 @@ ] }, "examples": { - "Not found": { - "$ref": "../examples/short-url-not-found.json" + "API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -203,8 +206,11 @@ ] }, "examples": { - "Invalid arguments": { - "$ref": "../examples/short-url-invalid-args.json" + "API v3 and newer": { + "$ref": "../examples/short-url-invalid-args-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-invalid-args-v2.json" } } } @@ -236,8 +242,11 @@ ] }, "examples": { - "Not found": { - "$ref": "../examples/short-url-not-found.json" + "API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -318,13 +327,27 @@ } ] }, - "example": { - "title": "Cannot delete short URL", - "type": "INVALID_SHORT_URL_DELETION", - "detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.", - "status": 422, - "shortCode": "abc123", - "threshold": 15 + "examples": { + "API v3 and newer": { + "value": { + "title": "Cannot delete short URL", + "type": "https://shlink.io/api/error/invalid-short-url-deletion", + "detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.", + "status": 422, + "shortCode": "abc123", + "threshold": 15 + } + }, + "Previous to API v3": { + "value": { + "title": "Cannot delete short URL", + "type": "INVALID_SHORT_URL_DELETION", + "detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.", + "status": 422, + "shortCode": "abc123", + "threshold": 15 + } + } } } } @@ -355,8 +378,11 @@ ] }, "examples": { - "Not found": { - "$ref": "../examples/short-url-not-found.json" + "API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index 08a93b68..e86bb698 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -151,8 +151,11 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Short URL not found": { - "$ref": "../examples/short-url-not-found.json" + "Short URL not found with API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Short URL not found previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index a8219bf1..0e77cf3c 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -188,12 +188,25 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "title": "Invalid data", - "type": "INVALID_ARGUMENT", - "detail": "Provided data is not valid", - "status": 400, - "invalidElements": ["oldName", "newName"] + "examples": { + "API v3 and newer": { + "value": { + "title": "Invalid data", + "type": "https://shlink.io/api/error/invalid-data", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["oldName", "newName"] + } + }, + "Previous to API v3": { + "value": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["oldName", "newName"] + } + } } } } @@ -205,11 +218,23 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "detail": "You are not allowed to rename tags", - "title": "Forbidden tag operation", - "type": "FORBIDDEN_OPERATION", - "status": 403 + "examples": { + "API v3 and newer": { + "value": { + "detail": "You are not allowed to rename tags", + "title": "Forbidden tag operation", + "type": "https://shlink.io/api/error/forbidden-tag-operation", + "status": 403 + } + }, + "Previous to API v3": { + "value": { + "detail": "You are not allowed to rename tags", + "title": "Forbidden tag operation", + "type": "FORBIDDEN_OPERATION", + "status": 403 + } + } } } } @@ -222,8 +247,11 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Tag not found": { - "$ref": "../examples/tag-not-found.json" + "API v3 and newer": { + "$ref": "../examples/tag-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/tag-not-found-v2.json" } } } @@ -236,13 +264,27 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "detail": "You cannot rename tag foo, because it already exists", - "title": "Tag conflict", - "type": "TAG_CONFLICT", - "status": 409, - "oldName": "bar", - "newName": "foo" + "examples": { + "API v3 and newer": { + "value": { + "detail": "You cannot rename tag foo, because it already exists", + "title": "Tag conflict", + "type": "https://shlink.io/api/error/tag-conflict", + "status": 409, + "oldName": "bar", + "newName": "foo" + } + }, + "Previous to API v3": { + "value": { + "detail": "You cannot rename tag foo, because it already exists", + "title": "Tag conflict", + "type": "TAG_CONFLICT", + "status": 409, + "oldName": "bar", + "newName": "foo" + } + } } } } @@ -300,11 +342,23 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "detail": "You are not allowed to delete tags", - "title": "Forbidden tag operation", - "type": "FORBIDDEN_OPERATION", - "status": 403 + "examples": { + "API v3 and newer": { + "value": { + "detail": "You are not allowed to delete tags", + "title": "Forbidden tag operation", + "type": "https://shlink.io/api/error/forbidden-tag-operation", + "status": 403 + } + }, + "Previous to API v3": { + "value": { + "detail": "You are not allowed to delete tags", + "title": "Forbidden tag operation", + "type": "FORBIDDEN_OPERATION", + "status": 403 + } + } } } } diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json index d4d4338c..cc328040 100644 --- a/docs/swagger/paths/v2_domains_redirects.json +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -94,12 +94,25 @@ } ] }, - "example": { - "title": "Invalid data", - "type": "INVALID_ARGUMENT", - "detail": "Provided data is not valid", - "status": 400, - "invalidElements": ["domain", "invalidShortUrlRedirect"] + "examples": { + "API v3 and newer": { + "value": { + "title": "Invalid data", + "type": "https://shlink.io/api/error/invalid-data", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["domain", "invalidShortUrlRedirect"] + } + }, + "Previous to API v3": { + "value": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["domain", "invalidShortUrlRedirect"] + } + } } } } diff --git a/docs/swagger/paths/v2_domains_{domain}_visits.json b/docs/swagger/paths/v2_domains_{domain}_visits.json index 33389f32..d3acf60e 100644 --- a/docs/swagger/paths/v2_domains_{domain}_visits.json +++ b/docs/swagger/paths/v2_domains_{domain}_visits.json @@ -147,12 +147,25 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "detail": "Domain with authority \"example.com\" could not be found", - "title": "Domain not found", - "type": "DOMAIN_NOT_FOUND", - "status": 404, - "authority": "example.com" + "examples": { + "API v3 and newer": { + "value": { + "detail": "Domain with authority \"example.com\" could not be found", + "title": "Domain not found", + "type": "https://shlink.io/api/error/domain-not-found", + "status": 404, + "authority": "example.com" + } + }, + "Previous to API v3": { + "value": { + "detail": "Domain with authority \"example.com\" could not be found", + "title": "Domain not found", + "type": "DOMAIN_NOT_FOUND", + "status": 404, + "authority": "example.com" + } + } } } } diff --git a/docs/swagger/paths/v2_mercure-info.json b/docs/swagger/paths/v2_mercure-info.json index a341573f..e637ca33 100644 --- a/docs/swagger/paths/v2_mercure-info.json +++ b/docs/swagger/paths/v2_mercure-info.json @@ -39,11 +39,23 @@ "schema": { "$ref": "../definitions/Error.json" }, - "example": { - "title": "Mercure integration not configured", - "type": "MERCURE_NOT_CONFIGURED", - "detail": "This Shlink instance is not integrated with a mercure hub.", - "status": 501 + "examples": { + "API v3 and newer": { + "value": { + "title": "Mercure integration not configured", + "type": "https://shlink.io/api/error/mercure-not-configured", + "detail": "This Shlink instance is not integrated with a mercure hub.", + "status": 501 + } + }, + "Previous to API v3": { + "value": { + "title": "Mercure integration not configured", + "type": "MERCURE_NOT_CONFIGURED", + "detail": "This Shlink instance is not integrated with a mercure hub.", + "status": 501 + } + } } } } diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index 109cb1d0..d40b7020 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -148,8 +148,12 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Tag not found": { - "$ref": "../examples/tag-not-found.json" + + "API v3 and newer": { + "$ref": "../examples/tag-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/tag-not-found-v2.json" } } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 840ac84e..b80ae3b2 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", - "version": "2.0" + "version": "3.0" }, "externalDocs": { diff --git a/indocker b/indocker index 789386ac..03061e2f 100755 --- a/indocker +++ b/indocker @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Run docker containers if they are not up yet -if ! [[ $(docker ps | grep shlink_swoole) ]]; then +if ! [[ $(docker ps | grep shlink) ]]; then docker-compose up -d fi diff --git a/infection-cli.json b/infection-cli.json new file mode 100644 index 00000000..60552d11 --- /dev/null +++ b/infection-cli.json @@ -0,0 +1,24 @@ +{ + "source": { + "directories": [ + "module/*/src" + ] + }, + "timeout": 5, + "logs": { + "text": "build/infection-cli/infection-log.txt", + "html": "build/infection-cli/infection-log.html", + "summary": "build/infection-cli/summary-log.txt", + "debug": "build/infection-cli/debug-log.txt" + }, + "tmpDir": "build/infection-cli/temp", + "phpUnit": { + "configDir": "." + }, + "testFrameworkOptions": "--configuration=phpunit-cli.xml", + "mutators": { + "@default": true, + "IdenticalEqual": false, + "NotIdenticalNotEqual": false + } +} diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 6920e839..dffc6010 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -19,7 +19,6 @@ use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; -use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Symfony\Component\Console as SymfonyCli; use Symfony\Component\Lock\LockFactory; @@ -35,7 +34,7 @@ return [ SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class, PhpExecutableFinder::class => InvokableFactory::class, - Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class, + GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class, Util\ProcessRunner::class => ConfigAbstractFactory::class, ApiKey\RoleResolver::class => ConfigAbstractFactory::class, @@ -70,7 +69,7 @@ return [ ], ConfigAbstractFactory::class => [ - Util\GeolocationDbUpdater::class => [ + GeoLite\GeolocationDbUpdater::class => [ DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY, @@ -92,10 +91,10 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], - Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class], + Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ Visit\VisitLocator::class, - IpLocationResolverInterface::class, + Visit\VisitToLocationHelper::class, LockFactory::class, ], Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class], diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 415290a3..5cc6a184 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -22,7 +22,7 @@ use const Shlinkio\Shlink\MIGRATIONS_TABLE; class CreateDatabaseCommand extends AbstractDatabaseCommand { public const NAME = 'db:create'; - public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php'; + public const DOCTRINE_SCRIPT = 'bin/doctrine'; public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create'; public function __construct( diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 6b4cce1a..666dea5b 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -40,7 +40,7 @@ class CreateShortUrlCommand extends Command private readonly UrlShortenerOptions $options, ) { parent::__construct(); - $this->defaultDomain = $this->options->domain()['hostname'] ?? ''; + $this->defaultDomain = $this->options->domain['hostname'] ?? ''; } protected function configure(): void @@ -158,7 +158,7 @@ class CreateShortUrlCommand extends Command $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); - $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength(); + $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; $doValidateUrl = $input->getOption('validate-url'); try { @@ -175,7 +175,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(), + EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, ])); $io->writeln([ diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 41fb5f8d..c4384d33 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index fe898dbb..59db9367 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -11,11 +11,11 @@ use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; +use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface; -use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; +use Shlinkio\Shlink\Core\Visit\VisitToLocationHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; @@ -34,8 +34,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat private SymfonyStyle $io; public function __construct( - private VisitLocatorInterface $visitLocator, - private IpLocationResolverInterface $ipLocationResolver, + private readonly VisitLocatorInterface $visitLocator, + private readonly VisitToLocationHelperInterface $visitToLocation, LockFactory $locker, ) { parent::__construct($locker); @@ -132,39 +132,33 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat */ public function geolocateVisit(Visit $visit): Location { - if (! $visit->hasRemoteAddr()) { - $this->io->writeln( - 'Ignored visit with no IP address', - OutputInterface::VERBOSITY_VERBOSE, - ); - throw IpCannotBeLocatedException::forEmptyAddress(); - } - - $ipAddr = $visit->getRemoteAddr() ?? ''; + $ipAddr = $visit->getRemoteAddr() ?? '?'; $this->io->write(sprintf('Processing IP %s', $ipAddr)); - if ($ipAddr === IpAddress::LOCALHOST) { - $this->io->writeln(' [Ignored localhost address]'); - throw IpCannotBeLocatedException::forLocalhost(); - } try { - return $this->ipLocationResolver->resolveIpLocation($ipAddr); - } catch (WrongIpException $e) { - $this->io->writeln(' [An error occurred while locating IP. Skipped]'); - if ($this->io->isVerbose()) { + return $this->visitToLocation->resolveVisitLocation($visit); + } catch (IpCannotBeLocatedException $e) { + $this->io->writeln(match ($e->type) { + UnlocatableIpType::EMPTY_ADDRESS => ' [Ignored visit with no IP address]', + UnlocatableIpType::LOCALHOST => ' [Ignored localhost address]', + UnlocatableIpType::ERROR => ' [An error occurred while locating IP. Skipped]', + }); + + if ($e->type === UnlocatableIpType::ERROR && $this->io->isVerbose()) { $this->getApplication()?->renderThrowable($e, $this->io); } - throw IpCannotBeLocatedException::forError($e); + throw $e; } } public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void { - $message = ! $visitLocation->isEmpty() - ? sprintf(' [Address located in "%s"]', $visitLocation->getCountryName()) - : ' [Address not found]'; - $this->io->writeln($message); + if (! $visitLocation->isEmpty()) { + $this->io->writeln(sprintf(' [Address located in "%s"]', $visitLocation->getCountryName())); + } elseif ($visit->hasRemoteAddr() && $visit->getRemoteAddr() !== IpAddress::LOCALHOST) { + $this->io->writeln(' [Could not locate address]'); + } } private function checkDbUpdate(): void diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index ef59d225..cbb8affd 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,16 +13,15 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; - private function __construct(string $message, int $code, ?Throwable $previous) + private function __construct(string $message, ?Throwable $previous = null) { - parent::__construct($message, $code, $previous); + parent::__construct($message, 0, $previous); } public static function withOlderDb(?Throwable $prev = null): self { $e = new self( 'An error occurred while updating geolocation database, but an older DB is already present.', - 0, $prev, ); $e->olderDbExists = true; @@ -34,7 +33,6 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { $e = new self( 'An error occurred while updating geolocation database, and an older version could not be found.', - 0, $prev, ); $e->olderDbExists = false; @@ -47,7 +45,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc $e = new self(sprintf( 'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.', $buildEpoch, - ), 0, null); + )); $e->olderDbExists = true; return $e; diff --git a/module/CLI/src/Factory/ApplicationFactory.php b/module/CLI/src/Factory/ApplicationFactory.php index 262238a3..ab716f7e 100644 --- a/module/CLI/src/Factory/ApplicationFactory.php +++ b/module/CLI/src/Factory/ApplicationFactory.php @@ -17,7 +17,7 @@ class ApplicationFactory $appOptions = $container->get(AppOptions::class); $commands = $config['commands'] ?? []; - $app = new CliApp($appOptions->getName(), $appOptions->getVersion()); + $app = new CliApp($appOptions->name, $appOptions->version); $app->setCommandLoader(new ContainerCommandLoader($container, $commands)); return $app; diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php similarity index 63% rename from module/CLI/src/Util/GeolocationDbUpdater.php rename to module/CLI/src/GeoLite/GeolocationDbUpdater.php index 22a3bac5..f33b8796 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -2,14 +2,16 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\CLI\Util; +namespace Shlinkio\Shlink\CLI\GeoLite; use Cake\Chronos\Chronos; use GeoIp2\Database\Reader; use MaxMind\Db\Reader\Metadata; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\Core\Options\TrackingOptions; -use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; +use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException; +use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException; +use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Symfony\Component\Lock\LockFactory; @@ -20,27 +22,27 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface private const LOCK_NAME = 'geolocation-db-update'; public function __construct( - private DbUpdaterInterface $dbUpdater, - private Reader $geoLiteDbReader, - private LockFactory $locker, - private TrackingOptions $trackingOptions, + private readonly DbUpdaterInterface $dbUpdater, + private readonly Reader $geoLiteDbReader, + private readonly LockFactory $locker, + private readonly TrackingOptions $trackingOptions, ) { } /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void + public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult { - if ($this->trackingOptions->disableTracking() || $this->trackingOptions->disableIpTracking()) { - return; + if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) { + return GeolocationResult::CHECK_SKIPPED; } $lock = $this->locker->createLock(self::LOCK_NAME); $lock->acquire(true); // Block until lock is released try { - $this->downloadIfNeeded($beforeDownload, $handleProgress); + return $this->downloadIfNeeded($beforeDownload, $handleProgress); } finally { $lock->release(); } @@ -49,17 +51,18 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void + private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult { if (! $this->dbUpdater->databaseFileExists()) { - $this->downloadNewDb(false, $beforeDownload, $handleProgress); - return; + return $this->downloadNewDb(false, $beforeDownload, $handleProgress); } $meta = $this->geoLiteDbReader->metadata(); if ($this->buildIsTooOld($meta)) { - $this->downloadNewDb(true, $beforeDownload, $handleProgress); + return $this->downloadNewDb(true, $beforeDownload, $handleProgress); } + + return GeolocationResult::DB_IS_UP_TO_DATE; } private function buildIsTooOld(Metadata $meta): bool @@ -92,15 +95,22 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadNewDb(bool $olderDbExists, ?callable $beforeDownload, ?callable $handleProgress): void - { + private function downloadNewDb( + bool $olderDbExists, + ?callable $beforeDownload, + ?callable $handleProgress, + ): GeolocationResult { if ($beforeDownload !== null) { $beforeDownload($olderDbExists); } try { $this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists)); - } catch (RuntimeException $e) { + return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED; + } catch (MissingLicenseException) { + // If there's no license key, just ignore the error + return GeolocationResult::CHECK_SKIPPED; + } catch (DbUpdateException | WrongIpException $e) { throw $olderDbExists ? GeolocationDbUpdateFailedException::withOlderDb($e) : GeolocationDbUpdateFailedException::withoutOlderDb($e); @@ -113,6 +123,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface return null; } - return fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists); + return static fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists); } } diff --git a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php similarity index 53% rename from module/CLI/src/Util/GeolocationDbUpdaterInterface.php rename to module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php index 714f6a11..a143abb8 100644 --- a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\CLI\Util; +namespace Shlinkio\Shlink\CLI\GeoLite; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; @@ -11,5 +11,8 @@ interface GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void; + public function checkDbUpdate( + ?callable $beforeDownload = null, + ?callable $handleProgress = null, + ): GeolocationResult; } diff --git a/module/CLI/src/GeoLite/GeolocationResult.php b/module/CLI/src/GeoLite/GeolocationResult.php new file mode 100644 index 00000000..7b245943 --- /dev/null +++ b/module/CLI/src/GeoLite/GeolocationResult.php @@ -0,0 +1,11 @@ +exec([GenerateKeyCommand::NAME]); + + self::assertStringContainsString('[OK] Generated API key', $output); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); + } +} diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php new file mode 100644 index 00000000..96b60e92 --- /dev/null +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -0,0 +1,63 @@ +exec([ListKeysCommand::NAME, ...$flags]); + + self::assertEquals($expectedOutput, $output); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); + } + + public function provideFlags(): iterable + { + $expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString(); + $enabledOnlyOutput = << [[], << [['-e'], $enabledOnlyOutput]; + yield '--enabled-only' => [['--enabled-only'], $enabledOnlyOutput]; + } +} diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 90942dc9..41a4f982 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -19,7 +19,7 @@ class DisableKeyCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $apiKeyService; - public function setUp(): void + protected function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal())); diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index e5c543d5..6db8581b 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -23,7 +23,7 @@ class GenerateKeyCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $apiKeyService; - public function setUp(): void + protected function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $roleResolver = $this->prophesize(RoleResolverInterface::class); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 68c1e844..7122f392 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -23,7 +23,7 @@ class ListKeysCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $apiKeyService; - public function setUp(): void + protected function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal())); diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 93e07d4d..f500775a 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -33,7 +33,7 @@ class CreateDatabaseCommandTest extends TestCase private ObjectProphecy $schemaManager; private ObjectProphecy $driver; - public function setUp(): void + protected function setUp(): void { $locker = $this->prophesize(LockFactory::class); $lock = $this->prophesize(LockInterface::class); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index d301f55e..1a8dfb0e 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -23,7 +23,7 @@ class MigrateDatabaseCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $processHelper; - public function setUp(): void + protected function setUp(): void { $locker = $this->prophesize(LockFactory::class); $lock = $this->prophesize(LockInterface::class); diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 6b6e1036..afcce551 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -24,7 +24,7 @@ class DomainRedirectsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $domainService; - public function setUp(): void + protected function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal())); diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 6d56ea69..51da498b 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -23,7 +23,7 @@ class ListDomainsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $domainService; - public function setUp(): void + protected function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal())); @@ -43,10 +43,10 @@ class ListDomainsCommandTest extends TestCase )); $listDomains = $this->domainService->listDomains()->willReturn([ - DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([ - 'base_url' => 'https://foo.com/default/base', - 'invalid_short_url' => 'https://foo.com/default/invalid', - ])), + DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions( + invalidShortUrl: 'https://foo.com/default/invalid', + baseUrl: 'https://foo.com/default/base', + )), DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), DomainItem::forNonDefaultDomain($bazDomain), ]); diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 73d2b785..733f6b72 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -30,7 +30,7 @@ class CreateShortUrlCommandTest extends TestCase private ObjectProphecy $urlShortener; private ObjectProphecy $stringifier; - public function setUp(): void + protected function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortener::class); $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); @@ -39,7 +39,7 @@ class CreateShortUrlCommandTest extends TestCase $command = new CreateShortUrlCommand( $this->urlShortener->reveal(), $this->stringifier->reveal(), - new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]), + new UrlShortenerOptions(domain: ['hostname' => self::DEFAULT_DOMAIN], defaultShortCodesLength: 5), ); $this->commandTester = $this->testerForCommand($command); } diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 947b7443..80d4878c 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -26,7 +26,7 @@ class DeleteShortUrlCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $service; - public function setUp(): void + protected function setUp(): void { $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal())); diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 316c762e..644a0b8f 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -33,7 +33,7 @@ class GetShortUrlVisitsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $visitsHelper; - public function setUp(): void + protected function setUp(): void { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal()); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index f9d701cb..51b02799 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -33,7 +33,7 @@ class ListShortUrlsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $shortUrlService; - public function setUp(): void + protected function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 12e29eaf..24974692 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -25,7 +25,7 @@ class ResolveUrlCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $urlResolver; - public function setUp(): void + protected function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal())); diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 46f61814..b03bf1ee 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -18,7 +18,7 @@ class DeleteTagsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal())); diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 499442d0..58ae1ef1 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -22,7 +22,7 @@ class ListTagsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal())); diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 3a52aba3..4d647fe7 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -21,7 +21,7 @@ class RenameTagCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal())); diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 62ea161a..93405799 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -9,8 +9,9 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; @@ -97,11 +98,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase public function provideSuccessParams(): iterable { - yield 'up to date db' => [function (): void { - }, '[INFO] GeoLite2 db file is up to date.']; - yield 'outdated db' => [function (array $args): void { + yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.']; + yield 'outdated db' => [function (array $args): GeolocationResult { [$beforeDownload] = $args; $beforeDownload(true); + + return GeolocationResult::DB_CREATED; }, '[OK] GeoLite2 db file properly downloaded.']; } } diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index fa666516..63ad3e52 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -10,16 +10,16 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitLocator; +use Shlinkio\Shlink\Core\Visit\VisitToLocationHelperInterface; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; @@ -36,14 +36,14 @@ class LocateVisitsCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $visitService; - private ObjectProphecy $ipResolver; + private ObjectProphecy $visitToLocation; private ObjectProphecy $lock; private ObjectProphecy $downloadDbCommand; - public function setUp(): void + protected function setUp(): void { $this->visitService = $this->prophesize(VisitLocator::class); - $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); + $this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class); $locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); @@ -54,7 +54,7 @@ class LocateVisitsCommandTest extends TestCase $command = new LocateVisitsCommand( $this->visitService->reveal(), - $this->ipResolver->reveal(), + $this->visitToLocation->reveal(), $locker->reveal(), ); @@ -84,7 +84,7 @@ class LocateVisitsCommandTest extends TestCase $mockMethodBehavior, ); $locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior); - $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn( + $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willReturn( Location::emptyInstance(), ); @@ -117,36 +117,29 @@ class LocateVisitsCommandTest extends TestCase * @test * @dataProvider provideIgnoredAddresses */ - public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void + public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, '')); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( $this->invokeHelperMethods($visit, $location), ); - $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn( - Location::emptyInstance(), - ); + $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow($e); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('Processing IP', $output); self::assertStringContainsString($message, $output); - if (empty($address)) { - self::assertStringNotContainsString('Processing IP', $output); - } else { - self::assertStringContainsString('Processing IP', $output); - } $locateVisits->shouldHaveBeenCalledOnce(); - $resolveIpLocation->shouldNotHaveBeenCalled(); + $resolveIpLocation->shouldHaveBeenCalledOnce(); } public function provideIgnoredAddresses(): iterable { - yield 'with empty address' => ['', 'Ignored visit with no IP address']; - yield 'with null address' => [null, 'Ignored visit with no IP address']; - yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address']; + yield 'empty address' => [IpCannotBeLocatedException::forEmptyAddress(), 'Ignored visit with no IP address']; + yield 'localhost address' => [IpCannotBeLocatedException::forLocalhost(), 'Ignored localhost address']; } /** @test */ @@ -158,7 +151,9 @@ class LocateVisitsCommandTest extends TestCase $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( $this->invokeHelperMethods($visit, $location), ); - $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class); + $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow( + IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')), + ); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); @@ -187,7 +182,7 @@ class LocateVisitsCommandTest extends TestCase $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void { }); - $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]); + $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any()); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/ConfigProviderTest.php b/module/CLI/test/ConfigProviderTest.php index 863b8a1f..f01649e0 100644 --- a/module/CLI/test/ConfigProviderTest.php +++ b/module/CLI/test/ConfigProviderTest.php @@ -12,7 +12,7 @@ class ConfigProviderTest extends TestCase { private ConfigProvider $configProvider; - public function setUp(): void + protected function setUp(): void { $this->configProvider = new ConfigProvider(); } diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index fbb5ace9..cb08a692 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -16,7 +16,7 @@ class ApplicationFactoryTest extends TestCase private ApplicationFactory $factory; - public function setUp(): void + protected function setUp(): void { $this->factory = new ApplicationFactory(); } diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php similarity index 80% rename from module/CLI/test/Util/GeolocationDbUpdaterTest.php rename to module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index c5e3bdb4..61056922 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\CLI\Util; +namespace ShlinkioTest\Shlink\CLI\GeoLite; use Cake\Chronos\Chronos; use GeoIp2\Database\Reader; @@ -12,9 +12,10 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; use Shlinkio\Shlink\Core\Options\TrackingOptions; -use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; +use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Symfony\Component\Lock; use Throwable; @@ -29,42 +30,32 @@ class GeolocationDbUpdaterTest extends TestCase private GeolocationDbUpdater $geolocationDbUpdater; private ObjectProphecy $dbUpdater; private ObjectProphecy $geoLiteDbReader; - private TrackingOptions $trackingOptions; private ObjectProphecy $lock; - public function setUp(): void + protected function setUp(): void { $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); $this->geoLiteDbReader = $this->prophesize(Reader::class); $this->trackingOptions = new TrackingOptions(); - $locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); $this->lock->acquire(true)->willReturn(true); $this->lock->release()->will(function (): void { }); - $locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); - - $this->geolocationDbUpdater = new GeolocationDbUpdater( - $this->dbUpdater->reveal(), - $this->geoLiteDbReader->reveal(), - $locker->reveal(), - $this->trackingOptions, - ); } /** @test */ public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void { $mustBeUpdated = fn () => self::assertTrue(true); - $prev = new RuntimeException(''); + $prev = new DbUpdateException(''); $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false); $getMeta = $this->geoLiteDbReader->metadata(); $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev); try { - $this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated); + $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); self::assertTrue(false); // If this is reached, the test will fail } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ @@ -90,11 +81,11 @@ class GeolocationDbUpdaterTest extends TestCase $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch( Chronos::now()->subDays($days)->getTimestamp(), )); - $prev = new RuntimeException(''); + $prev = new DbUpdateException(''); $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev); try { - $this->geolocationDbUpdater->checkDbUpdate(); + $this->geolocationDbUpdater()->checkDbUpdate(); self::assertTrue(false); // If this is reached, the test will fail } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ @@ -120,15 +111,16 @@ class GeolocationDbUpdaterTest extends TestCase * @test * @dataProvider provideSmallDays */ - public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(string|int $buildEpoch): void + public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void { $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch)); $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void { }); - $this->geolocationDbUpdater->checkDbUpdate(); + $result = $this->geolocationDbUpdater()->checkDbUpdate(); + self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result); $fileExists->shouldHaveBeenCalledOnce(); $getMeta->shouldHaveBeenCalledOnce(); $download->shouldNotHaveBeenCalled(); @@ -160,7 +152,7 @@ class GeolocationDbUpdaterTest extends TestCase $getMeta->shouldBeCalledOnce(); $download->shouldNotBeCalled(); - $this->geolocationDbUpdater->checkDbUpdate(); + $this->geolocationDbUpdater()->checkDbUpdate(); } private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata @@ -182,22 +174,32 @@ class GeolocationDbUpdaterTest extends TestCase * @test * @dataProvider provideTrackingOptions */ - public function downloadDbIsSkippedIfTrackingIsDisabled(array $props): void + public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void { - foreach ($props as $prop) { - $this->trackingOptions->{$prop} = true; - } - - $this->geolocationDbUpdater->checkDbUpdate(); + $result = $this->geolocationDbUpdater($options)->checkDbUpdate(); + self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result); $this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled(); $this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideTrackingOptions(): iterable { - yield 'disableTracking' => [['disableTracking']]; - yield 'disableIpTracking' => [['disableIpTracking']]; - yield 'both' => [['disableTracking', 'disableIpTracking']]; + yield 'disableTracking' => [new TrackingOptions(disableTracking: true)]; + yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)]; + yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)]; + } + + private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater + { + $locker = $this->prophesize(Lock\LockFactory::class); + $locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); + + return new GeolocationDbUpdater( + $this->dbUpdater->reveal(), + $this->geoLiteDbReader->reveal(), + $locker->reveal(), + $options ?? new TrackingOptions(), + ); } } diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index 1ca612d4..ffe1f30d 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -21,7 +21,7 @@ class ShlinkTableTest extends TestCase private ShlinkTable $shlinkTable; private ObjectProphecy $baseTable; - public function setUp(): void + protected function setUp(): void { $this->baseTable = $this->prophesize(Table::class); $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal()); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9edc5fc2..49b2857a 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -7,9 +7,11 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; +use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; return [ @@ -20,14 +22,14 @@ return [ ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class, ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class, - Options\AppOptions::class => ConfigAbstractFactory::class, - Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, - Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class, - Options\RedirectOptions::class => ConfigAbstractFactory::class, - Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, - Options\TrackingOptions::class => ConfigAbstractFactory::class, - Options\QrCodeOptions::class => ConfigAbstractFactory::class, - Options\RabbitMqOptions::class => ConfigAbstractFactory::class, + Options\AppOptions::class => [ValinorConfigFactory::class, 'config.app_options'], + Options\DeleteShortUrlsOptions::class => [ValinorConfigFactory::class, 'config.delete_short_urls'], + Options\NotFoundRedirectOptions::class => [ValinorConfigFactory::class, 'config.not_found_redirects'], + Options\RedirectOptions::class => [ValinorConfigFactory::class, 'config.redirects'], + Options\UrlShortenerOptions::class => [ValinorConfigFactory::class, 'config.url_shortener'], + Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'], + Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], + Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], Options\WebhookOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, @@ -43,6 +45,7 @@ return [ Visit\VisitsTracker::class => ConfigAbstractFactory::class, Visit\RequestTracker::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class, + Visit\VisitToLocationHelper::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class, @@ -85,14 +88,6 @@ return [ Domain\DomainService::class, ], - Options\AppOptions::class => ['config.app_options'], - Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], - Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'], - Options\RedirectOptions::class => ['config.redirects'], - Options\UrlShortenerOptions::class => ['config.url_shortener'], - Options\TrackingOptions::class => ['config.tracking'], - Options\QrCodeOptions::class => ['config.qr_codes'], - Options\RabbitMqOptions::class => ['config.rabbitmq'], Options\WebhookOptions::class => ['config.visits_webhooks'], Service\UrlShortener::class => [ @@ -115,6 +110,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, ], Visit\VisitLocator::class => ['em'], + Visit\VisitToLocationHelper::class => [IpLocationResolverInterface::class], Visit\VisitsStatsHelper::class => ['em'], Tag\TagService::class => ['em'], Service\ShortUrl\DeleteShortUrlService::class => [ diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 467f63cc..3d473010 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Psr\EventDispatcher\EventDispatcherInterface; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +use Shlinkio\Shlink\Core\Visit\VisitLocator; +use Shlinkio\Shlink\Core\Visit\VisitToLocationHelper; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; @@ -20,6 +22,9 @@ return [ EventDispatcher\Event\UrlVisited::class => [ EventDispatcher\LocateVisit::class, ], + EventDispatcher\Event\GeoLiteDbCreated::class => [ + EventDispatcher\LocateUnlocatedVisits::class, + ], ], 'async' => [ EventDispatcher\Event\VisitLocated::class => [ @@ -40,6 +45,7 @@ return [ 'dependencies' => [ 'factories' => [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, + EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, @@ -69,6 +75,9 @@ return [ EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], + EventDispatcher\LocateUnlocatedVisits::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], EventDispatcher\NotifyVisitToWebHooks::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], @@ -83,6 +92,7 @@ return [ DbUpdater::class, EventDispatcherInterface::class, ], + EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], EventDispatcher\NotifyVisitToWebHooks::class => [ 'httpClient', 'em', @@ -132,7 +142,11 @@ return [ 'Logger_Shlink', 'config.redis.pub_sub_enabled', ], - EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], + EventDispatcher\UpdateGeoLiteDb::class => [ + GeolocationDbUpdater::class, + 'Logger_Shlink', + EventDispatcherInterface::class, + ], ], ]; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index c5186e41..d34175c7 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -127,3 +127,8 @@ function camelCaseToHumanFriendly(string $value): string return ucfirst($filter->filter($value)); } + +function toProblemDetailsType(string $errorCode): string +{ + return sprintf('https://shlink.io/api/error/%s', $errorCode); +} diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 8e9aaa09..0bf86258 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -19,8 +19,8 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { public function __construct( - private ShortUrlResolverInterface $urlResolver, - private RequestTrackerInterface $requestTracker, + private readonly ShortUrlResolverInterface $urlResolver, + private readonly RequestTrackerInterface $requestTracker, ) { } diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 7c1f0e34..306c2b44 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -52,7 +52,7 @@ final class QrCodeParams private static function resolveSize(array $query, QrCodeOptions $defaults): int { - $size = (int) ($query['size'] ?? $defaults->size()); + $size = (int) ($query['size'] ?? $defaults->size); if ($size < self::MIN_SIZE) { return self::MIN_SIZE; } @@ -62,7 +62,7 @@ final class QrCodeParams private static function resolveMargin(array $query, QrCodeOptions $defaults): int { - $margin = $query['margin'] ?? (string) $defaults->margin(); + $margin = $query['margin'] ?? (string) $defaults->margin; $intMargin = (int) $margin; if ($margin !== (string) $intMargin) { return 0; @@ -74,7 +74,7 @@ final class QrCodeParams private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface { $qFormat = self::normalizeParam($query['format'] ?? ''); - $format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format()); + $format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format); return match ($format) { 'svg' => new SvgWriter(), @@ -84,7 +84,7 @@ final class QrCodeParams private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface { - $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection()); + $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection); return match ($errorCorrectionLevel) { 'h' => new ErrorCorrectionLevelHigh(), 'q' => new ErrorCorrectionLevelQuartile(), @@ -97,7 +97,7 @@ final class QrCodeParams { $doNotRoundBlockSize = isset($query['roundBlockSize']) ? $query['roundBlockSize'] === 'false' - : ! $defaults->roundBlockSize(); + : ! $defaults->roundBlockSize; return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index ae93e4da..228a5921 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -43,9 +43,11 @@ enum EnvVars: string case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE'; case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; case BASE_PATH = 'BASE_PATH'; + case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; case PORT = 'PORT'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; + case INITIAL_API_KEY = 'INITIAL_API_KEY'; case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 6fadaa5d..907b3d9c 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -5,10 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher\Event; use JsonSerializable; +use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; -abstract class AbstractVisitEvent implements JsonSerializable +abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable { - public function __construct(public readonly string $visitId) + final public function __construct(public readonly string $visitId) { } @@ -16,4 +17,9 @@ abstract class AbstractVisitEvent implements JsonSerializable { return ['visitId' => $this->visitId]; } + + public static function fromPayload(array $payload): self + { + return new static($payload['visitId'] ?? ''); + } } diff --git a/module/Core/src/EventDispatcher/Event/GeoLiteDbCreated.php b/module/Core/src/EventDispatcher/Event/GeoLiteDbCreated.php new file mode 100644 index 00000000..3fc86cd7 --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/GeoLiteDbCreated.php @@ -0,0 +1,9 @@ + $this->shortUrlId, ]; } + + public static function fromPayload(array $payload): self + { + return new self($payload['shortUrlId'] ?? ''); + } } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index 02452a3e..c57d59d6 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,8 +6,18 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - public function __construct(string $visitId, public readonly ?string $originalIpAddress = null) + private ?string $originalIpAddress = null; + + public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self { - parent::__construct($visitId); + $instance = new self($visitId); + $instance->originalIpAddress = $originalIpAddress; + + return $instance; + } + + public function originalIpAddress(): ?string + { + return $this->originalIpAddress; } } diff --git a/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php b/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php new file mode 100644 index 00000000..c036c450 --- /dev/null +++ b/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php @@ -0,0 +1,40 @@ +locator->locateUnlocatedVisits($this); + } + + /** + * @throws IpCannotBeLocatedException + */ + public function geolocateVisit(Visit $visit): Location + { + return $this->visitToLocation->resolveVisitLocation($visit); + } + + public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void + { + } +} diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 197ce9a0..8708fb8a 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -41,7 +41,7 @@ class LocateVisit return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php index 488247d7..daa7cafb 100644 --- a/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php @@ -26,7 +26,7 @@ class NotifyNewShortUrlToRabbitMq extends AbstractNotifyNewShortUrlListener protected function isEnabled(): bool { - return $this->options->isEnabled(); + return $this->options->enabled; } protected function getRemoteSystem(): RemoteSystem diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php index 0faa795c..989de0a5 100644 --- a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -35,7 +35,7 @@ class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener protected function determineUpdatesForVisit(Visit $visit): array { // Once the two deprecated cases below have been removed, make parent method private - if (! $this->options->legacyVisitsPublishing()) { + if (! $this->options->legacyVisitsPublishing) { return parent::determineUpdatesForVisit($visit); } @@ -61,7 +61,7 @@ class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener protected function isEnabled(): bool { - return $this->options->isEnabled(); + return $this->options->enabled; } protected function getRemoteSystem(): RemoteSystem diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php index 13941f43..f19378ea 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -4,16 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; +use Shlinkio\Shlink\Core\EventDispatcher\Event\GeoLiteDbCreated; use Throwable; use function sprintf; class UpdateGeoLiteDb { - public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger) - { + public function __construct( + private readonly GeolocationDbUpdaterInterface $dbUpdater, + private readonly LoggerInterface $logger, + private readonly EventDispatcherInterface $eventDispatcher, + ) { } public function __invoke(): void @@ -32,7 +38,10 @@ class UpdateGeoLiteDb }; try { - $this->dbUpdater->checkDbUpdate($beforeDownload, $handleProgress); + $result = $this->dbUpdater->checkDbUpdate($beforeDownload, $handleProgress); + if ($result === GeolocationResult::DB_CREATED) { + $this->eventDispatcher->dispatch(new GeoLiteDbCreated()); + } } catch (Throwable $e) { $this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]); } diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index 0d331400..f8a5cfa8 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE use CommonProblemDetailsExceptionTrait; private const TITLE = 'Cannot delete short URL'; - private const TYPE = 'INVALID_SHORT_URL_DELETION'; + public const ERROR_CODE = 'invalid-short-url-deletion'; public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self { @@ -32,7 +33,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY; $e->additional = [ 'shortCode' => $shortCode, diff --git a/module/Core/src/Exception/DomainNotFoundException.php b/module/Core/src/Exception/DomainNotFoundException.php index cb19608a..688a4edc 100644 --- a/module/Core/src/Exception/DomainNotFoundException.php +++ b/module/Core/src/Exception/DomainNotFoundException.php @@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class DomainNotFoundException extends DomainException implements ProblemDetailsExceptionInterface @@ -15,7 +16,7 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE use CommonProblemDetailsExceptionTrait; private const TITLE = 'Domain not found'; - private const TYPE = 'DOMAIN_NOT_FOUND'; + public const ERROR_CODE = 'domain-not-found'; private function __construct(string $message, array $additional) { @@ -23,7 +24,7 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE $this->detail = $message; $this->title = self::TITLE; - $this->type = self::TYPE; + $this->type = toProblemDetailsType(self::ERROR_CODE); $this->status = StatusCodeInterface::STATUS_NOT_FOUND; $this->additional = $additional; } diff --git a/module/Core/src/Exception/ForbiddenTagOperationException.php b/module/Core/src/Exception/ForbiddenTagOperationException.php index d4200c92..64ae156c 100644 --- a/module/Core/src/Exception/ForbiddenTagOperationException.php +++ b/module/Core/src/Exception/ForbiddenTagOperationException.php @@ -8,12 +8,14 @@ use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function Shlinkio\Shlink\Core\toProblemDetailsType; + class ForbiddenTagOperationException extends DomainException implements ProblemDetailsExceptionInterface { use CommonProblemDetailsExceptionTrait; private const TITLE = 'Forbidden tag operation'; - private const TYPE = 'FORBIDDEN_OPERATION'; + public const ERROR_CODE = 'forbidden-tag-operation'; public static function forDeletion(): self { @@ -31,7 +33,7 @@ class ForbiddenTagOperationException extends DomainException implements ProblemD $e->detail = $message; $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_FORBIDDEN; return $e; diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php index ee4caaf6..200914c2 100644 --- a/module/Core/src/Exception/InvalidUrlException.php +++ b/module/Core/src/Exception/InvalidUrlException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Throwable; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid URL'; - private const TYPE = 'INVALID_URL'; + public const ERROR_CODE = 'invalid-url'; public static function fromUrl(string $url, ?Throwable $previous = null): self { @@ -25,7 +26,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = $status; $e->additional = ['url' => $url]; diff --git a/module/Core/src/Exception/IpCannotBeLocatedException.php b/module/Core/src/Exception/IpCannotBeLocatedException.php index b1ba731c..2ebc3e62 100644 --- a/module/Core/src/Exception/IpCannotBeLocatedException.php +++ b/module/Core/src/Exception/IpCannotBeLocatedException.php @@ -4,35 +4,40 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; +use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Throwable; class IpCannotBeLocatedException extends RuntimeException { - private bool $isNonLocatableAddress = true; + private function __construct( + string $message, + public readonly UnlocatableIpType $type, + int $code = 0, + ?Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } public static function forEmptyAddress(): self { - return new self('Ignored visit with no IP address'); + return new self('Ignored visit with no IP address', UnlocatableIpType::EMPTY_ADDRESS); } public static function forLocalhost(): self { - return new self('Ignored localhost address'); + return new self('Ignored localhost address', UnlocatableIpType::LOCALHOST); } public static function forError(Throwable $e): self { - $e = new self('An error occurred while locating IP', $e->getCode(), $e); - $e->isNonLocatableAddress = false; - - return $e; + return new self('An error occurred while locating IP', UnlocatableIpType::ERROR, $e->getCode(), $e); } /** - * Tells if this error belongs to an address that will never be possible locate + * Tells if this belongs to an address that will never be possible to locate */ public function isNonLocatableAddress(): bool { - return $this->isNonLocatableAddress; + return $this->type !== UnlocatableIpType::ERROR; } } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index f61c480f..5336786c 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class NonUniqueSlugException extends InvalidArgumentException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid custom slug'; - private const TYPE = 'INVALID_SLUG'; + public const ERROR_CODE = 'non-unique-slug'; public static function fromSlug(string $slug, ?string $domain = null): self { @@ -25,7 +26,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; $e->additional = ['customSlug' => $slug]; diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index c59c20ef..49b8cc02 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class ShortUrlNotFoundException extends DomainException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail use CommonProblemDetailsExceptionTrait; private const TITLE = 'Short URL not found'; - private const TYPE = 'INVALID_SHORTCODE'; + public const ERROR_CODE = 'short-url-not-found'; public static function fromNotFound(ShortUrlIdentifier $identifier): self { @@ -27,7 +28,7 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_NOT_FOUND; $e->additional = ['shortCode' => $shortCode]; diff --git a/module/Core/src/Exception/TagConflictException.php b/module/Core/src/Exception/TagConflictException.php index d551ec19..0fc5c317 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class TagConflictException extends RuntimeException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc use CommonProblemDetailsExceptionTrait; private const TITLE = 'Tag conflict'; - private const TYPE = 'TAG_CONFLICT'; + public const ERROR_CODE = 'tag-conflict'; public static function forExistingTag(TagRenaming $renaming): self { @@ -24,7 +25,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_CONFLICT; $e->additional = $renaming->toArray(); diff --git a/module/Core/src/Exception/TagNotFoundException.php b/module/Core/src/Exception/TagNotFoundException.php index 18c1554c..8fdd395a 100644 --- a/module/Core/src/Exception/TagNotFoundException.php +++ b/module/Core/src/Exception/TagNotFoundException.php @@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class TagNotFoundException extends DomainException implements ProblemDetailsExceptionInterface @@ -15,7 +16,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce use CommonProblemDetailsExceptionTrait; private const TITLE = 'Tag not found'; - private const TYPE = 'TAG_NOT_FOUND'; + public const ERROR_CODE = 'tag-not-found'; public static function fromTag(string $tag): self { @@ -23,7 +24,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_NOT_FOUND; $e->additional = ['tag' => $tag]; diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 326eec11..dcb11fa4 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -12,6 +12,7 @@ use Throwable; use function array_keys; use function Shlinkio\Shlink\Core\arrayToString; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; use const PHP_EOL; @@ -21,7 +22,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid data'; - private const TYPE = 'INVALID_ARGUMENT'; + public const ERROR_CODE = 'invalid-data'; private array $invalidElements; @@ -37,7 +38,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; $e->invalidElements = $invalidData; $e->additional = ['invalidElements' => array_keys($invalidData)]; diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index 2207fad8..61663b95 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -69,9 +69,9 @@ final class Visitor public function normalizeForTrackingOptions(TrackingOptions $options): self { $instance = new self( - $options->disableUaTracking() ? '' : $this->userAgent, - $options->disableReferrerTracking() ? '' : $this->referer, - $options->disableIpTracking() ? null : $this->remoteAddress, + $options->disableUaTracking ? '' : $this->userAgent, + $options->disableReferrerTracking ? '' : $this->referer, + $options->disableIpTracking ? null : $this->remoteAddress, $this->visitedUrl, ); diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index e81f9fdb..ec545352 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -4,35 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use function sprintf; -class AppOptions extends AbstractOptions +final class AppOptions { - private string $name = 'Shlink'; - private string $version = '3.0.0'; - - public function getName(): string + public function __construct(public string $name = 'Shlink', public string $version = '3.0.0') { - return $this->name; - } - - protected function setName(string $name): self - { - $this->name = $name; - return $this; - } - - public function getVersion(): string - { - return $this->version; - } - - protected function setVersion(string $version): self - { - $this->version = $version; - return $this; } public function __toString(): string diff --git a/module/Core/src/Options/DeleteShortUrlsOptions.php b/module/Core/src/Options/DeleteShortUrlsOptions.php index ff1c356a..a645181b 100644 --- a/module/Core/src/Options/DeleteShortUrlsOptions.php +++ b/module/Core/src/Options/DeleteShortUrlsOptions.php @@ -4,34 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD; -class DeleteShortUrlsOptions extends AbstractOptions +final class DeleteShortUrlsOptions { - private int $visitsThreshold = DEFAULT_DELETE_SHORT_URL_THRESHOLD; - private bool $checkVisitsThreshold = true; - - public function getVisitsThreshold(): int - { - return $this->visitsThreshold; - } - - protected function setVisitsThreshold(int $visitsThreshold): self - { - $this->visitsThreshold = $visitsThreshold; - return $this; - } - - public function doCheckVisitsThreshold(): bool - { - return $this->checkVisitsThreshold; - } - - protected function setCheckVisitsThreshold(bool $checkVisitsThreshold): self - { - $this->checkVisitsThreshold = $checkVisitsThreshold; - return $this; + public function __construct( + public readonly int $visitsThreshold = DEFAULT_DELETE_SHORT_URL_THRESHOLD, + public readonly bool $checkVisitsThreshold = true, + ) { } } diff --git a/module/Core/src/Options/NotFoundRedirectOptions.php b/module/Core/src/Options/NotFoundRedirectOptions.php index 2f2d813b..fe99ac7e 100644 --- a/module/Core/src/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Options/NotFoundRedirectOptions.php @@ -4,14 +4,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirectConfigInterface +final class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface { - private ?string $invalidShortUrl = null; - private ?string $regular404 = null; - private ?string $baseUrl = null; + public function __construct( + public readonly ?string $invalidShortUrl = null, + public readonly ?string $regular404 = null, + public readonly ?string $baseUrl = null, + ) { + } public function invalidShortUrlRedirect(): ?string { @@ -23,12 +25,6 @@ class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirec return $this->invalidShortUrl !== null; } - protected function setInvalidShortUrl(?string $invalidShortUrl): self - { - $this->invalidShortUrl = $invalidShortUrl; - return $this; - } - public function regular404Redirect(): ?string { return $this->regular404; @@ -39,12 +35,6 @@ class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirec return $this->regular404 !== null; } - protected function setRegular404(?string $regular404): self - { - $this->regular404 = $regular404; - return $this; - } - public function baseUrlRedirect(): ?string { return $this->baseUrl; @@ -54,10 +44,4 @@ class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirec { return $this->baseUrl !== null; } - - protected function setBaseUrl(?string $baseUrl): self - { - $this->baseUrl = $baseUrl; - return $this; - } } diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php index 3dfc9a53..1b10c280 100644 --- a/module/Core/src/Options/QrCodeOptions.php +++ b/module/Core/src/Options/QrCodeOptions.php @@ -4,69 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; -class QrCodeOptions extends AbstractOptions +final class QrCodeOptions { - private int $size = DEFAULT_QR_CODE_SIZE; - private int $margin = DEFAULT_QR_CODE_MARGIN; - private string $format = DEFAULT_QR_CODE_FORMAT; - private string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION; - private bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; - - public function size(): int - { - return $this->size; - } - - protected function setSize(int $size): void - { - $this->size = $size; - } - - public function margin(): int - { - return $this->margin; - } - - protected function setMargin(int $margin): void - { - $this->margin = $margin; - } - - public function format(): string - { - return $this->format; - } - - protected function setFormat(string $format): void - { - $this->format = $format; - } - - public function errorCorrection(): string - { - return $this->errorCorrection; - } - - protected function setErrorCorrection(string $errorCorrection): void - { - $this->errorCorrection = $errorCorrection; - } - - public function roundBlockSize(): bool - { - return $this->roundBlockSize; - } - - protected function setRoundBlockSize(bool $roundBlockSize): void - { - $this->roundBlockSize = $roundBlockSize; + public function __construct( + public readonly int $size = DEFAULT_QR_CODE_SIZE, + public readonly int $margin = DEFAULT_QR_CODE_MARGIN, + public readonly string $format = DEFAULT_QR_CODE_FORMAT, + public readonly string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION, + public readonly bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, + ) { } } diff --git a/module/Core/src/Options/RabbitMqOptions.php b/module/Core/src/Options/RabbitMqOptions.php index 388cd2ea..cc25f3bf 100644 --- a/module/Core/src/Options/RabbitMqOptions.php +++ b/module/Core/src/Options/RabbitMqOptions.php @@ -4,37 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - -class RabbitMqOptions extends AbstractOptions +final class RabbitMqOptions { - protected $__strictMode__ = false; // phpcs:ignore - - private bool $enabled = false; - /** @deprecated */ - private bool $legacyVisitsPublishing = false; - - public function isEnabled(): bool - { - return $this->enabled; - } - - protected function setEnabled(bool $enabled): self - { - $this->enabled = $enabled; - return $this; - } - - /** @deprecated */ - public function legacyVisitsPublishing(): bool - { - return $this->legacyVisitsPublishing; - } - - /** @deprecated */ - protected function setLegacyVisitsPublishing(bool $legacyVisitsPublishing): self - { - $this->legacyVisitsPublishing = $legacyVisitsPublishing; - return $this; + public function __construct( + public readonly bool $enabled = false, + /** @deprecated */ + public readonly bool $legacyVisitsPublishing = false, + ) { } } diff --git a/module/Core/src/Options/RedirectOptions.php b/module/Core/src/Options/RedirectOptions.php index 5479c59b..9a1fedac 100644 --- a/module/Core/src/Options/RedirectOptions.php +++ b/module/Core/src/Options/RedirectOptions.php @@ -4,40 +4,23 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use function Functional\contains; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; -class RedirectOptions extends AbstractOptions +final class RedirectOptions { - private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; - private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; + public readonly int $redirectStatusCode; + public readonly int $redirectCacheLifetime; - public function redirectStatusCode(): int - { - return $this->redirectStatusCode; - } - - protected function setRedirectStatusCode(int $redirectStatusCode): void - { - $this->redirectStatusCode = $this->normalizeRedirectStatusCode($redirectStatusCode); - } - - private function normalizeRedirectStatusCode(int $statusCode): int - { - return contains([301, 302], $statusCode) ? $statusCode : DEFAULT_REDIRECT_STATUS_CODE; - } - - public function redirectCacheLifetime(): int - { - return $this->redirectCacheLifetime; - } - - protected function setRedirectCacheLifetime(int $redirectCacheLifetime): void - { + public function __construct( + int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE, + int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME, + ) { + $this->redirectStatusCode = contains([301, 302], $redirectStatusCode) + ? $redirectStatusCode + : DEFAULT_REDIRECT_STATUS_CODE; $this->redirectCacheLifetime = $redirectCacheLifetime > 0 ? $redirectCacheLifetime : DEFAULT_REDIRECT_CACHE_LIFETIME; diff --git a/module/Core/src/Options/TrackingOptions.php b/module/Core/src/Options/TrackingOptions.php index ba51b8e9..d4272374 100644 --- a/module/Core/src/Options/TrackingOptions.php +++ b/module/Core/src/Options/TrackingOptions.php @@ -4,103 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use function array_key_exists; -use function explode; -use function Functional\map; -use function is_array; -use function trim; -class TrackingOptions extends AbstractOptions +final class TrackingOptions { - private bool $anonymizeRemoteAddr = true; - private bool $trackOrphanVisits = true; - private ?string $disableTrackParam = null; - private bool $disableTracking = false; - private bool $disableIpTracking = false; - private bool $disableReferrerTracking = false; - private bool $disableUaTracking = false; - private array $disableTrackingFrom = []; - - public function anonymizeRemoteAddr(): bool - { - return $this->anonymizeRemoteAddr; - } - - protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void - { - $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; - } - - public function trackOrphanVisits(): bool - { - return $this->trackOrphanVisits; - } - - protected function setTrackOrphanVisits(bool $trackOrphanVisits): void - { - $this->trackOrphanVisits = $trackOrphanVisits; - } - - public function getDisableTrackParam(): ?string - { - return $this->disableTrackParam; - } - - public function queryHasDisableTrackParam(array $query): bool - { - return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query); - } - - protected function setDisableTrackParam(?string $disableTrackParam): void - { - $this->disableTrackParam = $disableTrackParam; - } - - public function disableTracking(): bool - { - return $this->disableTracking; - } - - protected function setDisableTracking(bool $disableTracking): void - { - $this->disableTracking = $disableTracking; - } - - public function disableIpTracking(): bool - { - return $this->disableIpTracking; - } - - protected function setDisableIpTracking(bool $disableIpTracking): void - { - $this->disableIpTracking = $disableIpTracking; - } - - public function disableReferrerTracking(): bool - { - return $this->disableReferrerTracking; - } - - protected function setDisableReferrerTracking(bool $disableReferrerTracking): void - { - $this->disableReferrerTracking = $disableReferrerTracking; - } - - public function disableUaTracking(): bool - { - return $this->disableUaTracking; - } - - protected function setDisableUaTracking(bool $disableUaTracking): void - { - $this->disableUaTracking = $disableUaTracking; - } - - public function disableTrackingFrom(): array - { - return $this->disableTrackingFrom; + public function __construct( + public readonly bool $anonymizeRemoteAddr = true, + public readonly bool $trackOrphanVisits = true, + public readonly ?string $disableTrackParam = null, + public readonly bool $disableTracking = false, + public readonly bool $disableIpTracking = false, + public readonly bool $disableReferrerTracking = false, + public readonly bool $disableUaTracking = false, + /** @var string[] */ + public readonly array $disableTrackingFrom = [], + ) { } public function hasDisableTrackingFrom(): bool @@ -108,12 +26,8 @@ class TrackingOptions extends AbstractOptions return ! empty($this->disableTrackingFrom); } - protected function setDisableTrackingFrom(string|array|null $disableTrackingFrom): void + public function queryHasDisableTrackParam(array $query): bool { - $this->disableTrackingFrom = match (true) { - is_array($disableTrackingFrom) => $disableTrackingFrom, - $disableTrackingFrom === null => [], - default => map(explode(',', $disableTrackingFrom), static fn (string $value) => trim($value)), - }; + return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query); } } diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 38e185c2..dd7fdc8d 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -4,69 +4,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use Laminas\Stdlib\AbstractOptions; - use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; -class UrlShortenerOptions extends AbstractOptions +final class UrlShortenerOptions { - protected $__strictMode__ = false; // phpcs:ignore - - private array $domain = []; - private int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH; - private bool $autoResolveTitles = false; - private bool $appendExtraPath = false; - private bool $multiSegmentSlugsEnabled = false; - - public function domain(): array - { - return $this->domain; - } - - protected function setDomain(array $domain): self - { - $this->domain = $domain; - return $this; - } - - public function defaultShortCodesLength(): int - { - return $this->defaultShortCodesLength; - } - - protected function setDefaultShortCodesLength(int $defaultShortCodesLength): self - { - $this->defaultShortCodesLength = $defaultShortCodesLength; - return $this; - } - - public function autoResolveTitles(): bool - { - return $this->autoResolveTitles; - } - - protected function setAutoResolveTitles(bool $autoResolveTitles): void - { - $this->autoResolveTitles = $autoResolveTitles; - } - - public function appendExtraPath(): bool - { - return $this->appendExtraPath; - } - - protected function setAppendExtraPath(bool $appendExtraPath): void - { - $this->appendExtraPath = $appendExtraPath; - } - - public function multiSegmentSlugsEnabled(): bool - { - return $this->multiSegmentSlugsEnabled; - } - - protected function setMultiSegmentSlugsEnabled(bool $multiSegmentSlugsEnabled): void - { - $this->multiSegmentSlugsEnabled = $multiSegmentSlugsEnabled; + public function __construct( + /** @var array{schema: ?string, hostname: ?string} */ + public readonly array $domain = ['schema' => null, 'hostname' => null], + public readonly int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH, + public readonly bool $autoResolveTitles = false, + public readonly bool $appendExtraPath = false, + public readonly bool $multiSegmentSlugsEnabled = false, + ) { } } diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 2c4e8db6..946cee7e 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -110,7 +110,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), - static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), + TagInfo::fromRawData(...), ); } diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index e6f2e82d..d4d6803f 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -32,7 +32,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { throw Exception\DeleteShortUrlException::fromVisitsThreshold( - $this->deleteShortUrlsOptions->getVisitsThreshold(), + $this->deleteShortUrlsOptions->visitsThreshold, $identifier, ); } @@ -43,10 +43,10 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface private function isThresholdReached(ShortUrl $shortUrl): bool { - if (! $this->deleteShortUrlsOptions->doCheckVisitsThreshold()) { + if (! $this->deleteShortUrlsOptions->checkVisitsThreshold) { return false; } - return $shortUrl->getVisitsCount() >= $this->deleteShortUrlsOptions->getVisitsThreshold(); + return $shortUrl->getVisitsCount() >= $this->deleteShortUrlsOptions->visitsThreshold; } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index 3251922d..985e2a3f 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -33,7 +33,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface { $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); - $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); + $disableTrackParam = $this->trackingOptions->disableTrackParam; if ($disableTrackParam !== null) { unset($currentQuery[$disableTrackParam]); } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index bb350aa2..3fead5f2 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -49,16 +49,16 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface private function shouldApplyLogic(?NotFoundType $notFoundType): bool { - if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath()) { + if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath) { return false; } return ( // If multi-segment slugs are enabled, the appropriate not-found type is "invalid_short_url" - $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isInvalidShortUrl() + $this->urlShortenerOptions->multiSegmentSlugsEnabled && $notFoundType->isInvalidShortUrl() ) || ( // If multi-segment slugs are disabled, the appropriate not-found type is "regular_404" - ! $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isRegularNotFound() + ! $this->urlShortenerOptions->multiSegmentSlugsEnabled && $notFoundType->isRegularNotFound() ); } @@ -79,7 +79,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { - if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled()) { + if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { return $handler->handle($request); } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 8a4f196b..5e71ea5b 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -15,6 +15,11 @@ final class TagInfo implements JsonSerializable ) { } + public static function fromRawData(array $data): self + { + return new self($data['tag'], (int) $data['shortUrlsCount'], (int) $data['visitsCount']); + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 633fd5f2..3b1d84b2 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -14,6 +14,7 @@ final class TagsParams extends AbstractInfinitePaginableListParams private function __construct( public readonly ?string $searchTerm, public readonly Ordering $orderBy, + /** @deprecated */ public readonly bool $withStats, ?int $page, ?int $itemsPerPage, diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index 312c2a95..dfc87480 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -19,9 +19,9 @@ class RedirectResponseHelper implements RedirectResponseHelperInterface public function buildRedirectResponse(string $location): ResponseInterface { - $statusCode = $this->options->redirectStatusCode(); + $statusCode = $this->options->redirectStatusCode; $headers = $statusCode === StatusCodeInterface::STATUS_FOUND ? [] : [ - 'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime()), + 'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime), ]; return new RedirectResponse($location, $statusCode, $headers); diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 2e2965b1..0057660a 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -46,11 +46,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface public function validateUrlWithTitle(string $url, bool $doValidate): ?string { - if (! $doValidate && ! $this->options->autoResolveTitles()) { + if (! $doValidate && ! $this->options->autoResolveTitles) { return null; } - if (! $this->options->autoResolveTitles()) { + if (! $this->options->autoResolveTitles) { $this->validateUrlAndGetResponse($url, self::METHOD_HEAD); return null; } diff --git a/module/Core/src/Visit/Model/UnlocatableIpType.php b/module/Core/src/Visit/Model/UnlocatableIpType.php new file mode 100644 index 00000000..56490209 --- /dev/null +++ b/module/Core/src/Visit/Model/UnlocatableIpType.php @@ -0,0 +1,10 @@ +trackingOptions->disableTrackingFrom(); + $disableTrackingFrom = $this->trackingOptions->disableTrackingFrom; return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool { $range = str_contains($value, '*') diff --git a/module/Core/src/Visit/VisitToLocationHelper.php b/module/Core/src/Visit/VisitToLocationHelper.php new file mode 100644 index 00000000..567d7544 --- /dev/null +++ b/module/Core/src/Visit/VisitToLocationHelper.php @@ -0,0 +1,40 @@ +hasRemoteAddr()) { + throw IpCannotBeLocatedException::forEmptyAddress(); + } + + $ipAddr = $visit->getRemoteAddr() ?? ''; + if ($ipAddr === IpAddress::LOCALHOST) { + throw IpCannotBeLocatedException::forLocalhost(); + } + + try { + return $this->ipLocationResolver->resolveIpLocation($ipAddr); + } catch (WrongIpException $e) { + throw IpCannotBeLocatedException::forError($e); + } + } +} diff --git a/module/Core/src/Visit/VisitToLocationHelperInterface.php b/module/Core/src/Visit/VisitToLocationHelperInterface.php new file mode 100644 index 00000000..7d553527 --- /dev/null +++ b/module/Core/src/Visit/VisitToLocationHelperInterface.php @@ -0,0 +1,17 @@ +trackVisit( - fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr()), + fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr), $visitor, ); } @@ -32,7 +32,7 @@ class VisitsTracker implements VisitsTrackerInterface public function trackInvalidShortUrlVisit(Visitor $visitor): void { $this->trackOrphanVisit( - fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr()), + fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr), $visitor, ); } @@ -40,7 +40,7 @@ class VisitsTracker implements VisitsTrackerInterface public function trackBaseUrlVisit(Visitor $visitor): void { $this->trackOrphanVisit( - fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr()), + fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr), $visitor, ); } @@ -48,14 +48,14 @@ class VisitsTracker implements VisitsTrackerInterface public function trackRegularNotFoundVisit(Visitor $visitor): void { $this->trackOrphanVisit( - fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr()), + fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr), $visitor, ); } private function trackOrphanVisit(callable $createVisit, Visitor $visitor): void { - if (! $this->options->trackOrphanVisits()) { + if (! $this->options->trackOrphanVisits) { return; } @@ -64,7 +64,7 @@ class VisitsTracker implements VisitsTrackerInterface private function trackVisit(callable $createVisit, Visitor $visitor): void { - if ($this->options->disableTracking()) { + if ($this->options->disableTracking) { return; } @@ -72,6 +72,6 @@ class VisitsTracker implements VisitsTrackerInterface $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index fdd291a5..2972d4fd 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -25,7 +25,7 @@ class PixelActionTest extends TestCase private ObjectProphecy $urlResolver; private ObjectProphecy $requestTracker; - public function setUp(): void + protected function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index fb9e4e6a..1f71975f 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; -use Mezzio\Router\RouterInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -35,24 +34,11 @@ class QrCodeActionTest extends TestCase private const WHITE = 0xFFFFFF; private const BLACK = 0x0; - private QrCodeAction $action; private ObjectProphecy $urlResolver; - private QrCodeOptions $options; - public function setUp(): void + protected function setUp(): void { - $router = $this->prophesize(RouterInterface::class); - $router->generateUri(Argument::cetera())->willReturn('/foo/bar'); - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->options = new QrCodeOptions(); - - $this->action = new QrCodeAction( - $this->urlResolver->reveal(), - new ShortUrlStringifier(['domain' => 'doma.in']), - new NullLogger(), - $this->options, - ); } /** @test */ @@ -65,7 +51,7 @@ class QrCodeActionTest extends TestCase $delegate = $this->prophesize(RequestHandlerInterface::class); $process = $delegate->handle(Argument::any())->willReturn(new Response()); - $this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal()); + $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal()); $process->shouldHaveBeenCalledOnce(); } @@ -79,7 +65,7 @@ class QrCodeActionTest extends TestCase ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); - $resp = $this->action->process( + $resp = $this->action()->process( (new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal(), ); @@ -98,7 +84,6 @@ class QrCodeActionTest extends TestCase array $query, string $expectedContentType, ): void { - $this->options->setFromArray(['format' => $defaultFormat]); $code = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), @@ -106,7 +91,7 @@ class QrCodeActionTest extends TestCase $delegate = $this->prophesize(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - $resp = $this->action->process($req, $delegate->reveal()); + $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate->reveal()); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); } @@ -128,18 +113,17 @@ class QrCodeActionTest extends TestCase * @dataProvider provideRequestsWithSize */ public function imageIsReturnedWithExpectedSize( - array $defaults, + QrCodeOptions $defaultOptions, ServerRequestInterface $req, int $expectedSize, ): void { - $this->options->setFromArray($defaults); $code = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), ); $delegate = $this->prophesize(RequestHandlerInterface::class); - $resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal()); + $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate->reveal()); [$size] = getimagesizefromstring($resp->getBody()->__toString()); self::assertEquals($expectedSize, $size); @@ -148,52 +132,64 @@ class QrCodeActionTest extends TestCase public function provideRequestsWithSize(): iterable { yield 'different margin and size defaults' => [ - ['size' => 660, 'margin' => 40], + new QrCodeOptions(size: 660, margin: 40), ServerRequestFactory::fromGlobals(), 740, ]; - yield 'no size' => [[], ServerRequestFactory::fromGlobals(), 300]; - yield 'no size, different default' => [['size' => 500], ServerRequestFactory::fromGlobals(), 500]; - yield 'size in query' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123]; + yield 'no size' => [new QrCodeOptions(), ServerRequestFactory::fromGlobals(), 300]; + yield 'no size, different default' => [new QrCodeOptions(size: 500), ServerRequestFactory::fromGlobals(), 500]; + yield 'size in query' => [ + new QrCodeOptions(), + ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), + 123, + ]; yield 'size in query, default margin' => [ - ['margin' => 25], + new QrCodeOptions(margin: 25), ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 173, ]; - yield 'margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370]; + yield 'margin' => [ + new QrCodeOptions(), + ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), + 370, + ]; yield 'margin and different default' => [ - ['size' => 400], + new QrCodeOptions(size: 400), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 470, ]; yield 'margin and size' => [ - [], + new QrCodeOptions(), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']), 400, ]; - yield 'negative margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300]; + yield 'negative margin' => [ + new QrCodeOptions(), + ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), + 300, + ]; yield 'negative margin, default margin' => [ - ['margin' => 10], + new QrCodeOptions(margin: 10), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300, ]; yield 'non-numeric margin' => [ - [], + new QrCodeOptions(), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']), 300, ]; yield 'negative margin and size' => [ - [], + new QrCodeOptions(), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']), 150, ]; yield 'negative margin and size, default margin' => [ - ['margin' => 5], + new QrCodeOptions(margin: 5), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']), 150, ]; yield 'non-numeric margin and size' => [ - [], + new QrCodeOptions(), ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']), 538, ]; @@ -204,11 +200,10 @@ class QrCodeActionTest extends TestCase * @dataProvider provideRoundBlockSize */ public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( - array $defaults, + QrCodeOptions $defaultOptions, ?string $roundBlockSize, int $expectedColor, ): void { - $this->options->setFromArray($defaults); $code = 'abc123'; $req = ServerRequestFactory::fromGlobals() ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) @@ -219,7 +214,7 @@ class QrCodeActionTest extends TestCase ); $delegate = $this->prophesize(RequestHandlerInterface::class); - $resp = $this->action->process($req, $delegate->reveal()); + $resp = $this->action($defaultOptions)->process($req, $delegate->reveal()); $image = imagecreatefromstring($resp->getBody()->__toString()); $color = imagecolorat($image, 1, 1); @@ -228,11 +223,33 @@ class QrCodeActionTest extends TestCase public function provideRoundBlockSize(): iterable { - yield 'no round block param' => [[], null, self::WHITE]; - yield 'no round block param, but disabled by default' => [['round_block_size' => false], null, self::BLACK]; - yield 'round block: "true"' => [[], 'true', self::WHITE]; - yield 'round block: "true", but disabled by default' => [['round_block_size' => false], 'true', self::WHITE]; - yield 'round block: "false"' => [[], 'false', self::BLACK]; - yield 'round block: "false", but enabled by default' => [['round_block_size' => true], 'false', self::BLACK]; + yield 'no round block param' => [new QrCodeOptions(), null, self::WHITE]; + yield 'no round block param, but disabled by default' => [ + new QrCodeOptions(roundBlockSize: false), + null, + self::BLACK, + ]; + yield 'round block: "true"' => [new QrCodeOptions(), 'true', self::WHITE]; + yield 'round block: "true", but disabled by default' => [ + new QrCodeOptions(roundBlockSize: false), + 'true', + self::WHITE, + ]; + yield 'round block: "false"' => [new QrCodeOptions(), 'false', self::BLACK]; + yield 'round block: "false", but enabled by default' => [ + new QrCodeOptions(roundBlockSize: true), + 'false', + self::BLACK, + ]; + } + + public function action(?QrCodeOptions $options = null): QrCodeAction + { + return new QrCodeAction( + $this->urlResolver->reveal(), + new ShortUrlStringifier(['domain' => 'doma.in']), + new NullLogger(), + $options ?? new QrCodeOptions(), + ); } } diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index cde2b9aa..aa2c9d07 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -31,7 +31,7 @@ class RedirectActionTest extends TestCase private ObjectProphecy $requestTracker; private ObjectProphecy $redirectRespHelper; - public function setUp(): void + protected function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index 36b038c8..2298a59c 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -11,7 +11,7 @@ class BasePathPrefixerTest extends TestCase { private BasePathPrefixer $prefixer; - public function setUp(): void + protected function setUp(): void { $this->prefixer = new BasePathPrefixer(); } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index aa98d102..912e17a5 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -60,57 +60,57 @@ class NotFoundRedirectResolverTest extends TestCase yield 'base URL with trailing slash' => [ $uri = new Uri('/'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['baseUrl' => 'baseUrl']), + new NotFoundRedirectOptions(baseUrl: 'baseUrl'), 'baseUrl', ]; yield 'base URL with domain placeholder' => [ $uri = new Uri('https://doma.in'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['baseUrl' => 'https://redirect-here.com/{DOMAIN}']), + new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'), 'https://redirect-here.com/doma.in', ]; yield 'base URL with domain placeholder in query' => [ $uri = new Uri('https://doma.in'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['baseUrl' => 'https://redirect-here.com/?domain={DOMAIN}']), + new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), 'https://redirect-here.com/?domain=doma.in', ]; yield 'base URL without trailing slash' => [ $uri = new Uri(''), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['baseUrl' => 'baseUrl']), + new NotFoundRedirectOptions(baseUrl: 'baseUrl'), 'baseUrl', ]; yield 'regular 404' => [ $uri = new Uri('/foo/bar'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['regular404' => 'regular404']), + new NotFoundRedirectOptions(regular404: 'regular404'), 'regular404', ]; yield 'regular 404 with path placeholder in query' => [ $uri = new Uri('/foo/bar'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(['regular404' => 'https://redirect-here.com/?path={ORIGINAL_PATH}']), + new NotFoundRedirectOptions(regular404: 'https://redirect-here.com/?path={ORIGINAL_PATH}'), 'https://redirect-here.com/?path=%2Ffoo%2Fbar', ]; yield 'regular 404 with multiple placeholders' => [ $uri = new Uri('https://doma.in/foo/bar'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions([ - 'regular404' => 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', - ]), + new NotFoundRedirectOptions( + regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', + ), 'https://redirect-here.com/foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar', ]; yield 'invalid short URL' => [ new Uri('/foo'), $this->notFoundType($this->requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(['invalidShortUrl' => 'invalidShortUrl']), + new NotFoundRedirectOptions(invalidShortUrl: 'invalidShortUrl'), 'invalidShortUrl', ]; yield 'invalid short URL with path placeholder' => [ new Uri('/foo'), $this->notFoundType($this->requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(['invalidShortUrl' => 'https://redirect-here.com/{ORIGINAL_PATH}']), + new NotFoundRedirectOptions(invalidShortUrl: 'https://redirect-here.com/{ORIGINAL_PATH}'), 'https://redirect-here.com/foo', ]; } diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index 33714f88..3bb0dbb4 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -12,7 +12,7 @@ class ConfigProviderTest extends TestCase { private ConfigProvider $configProvider; - public function setUp(): void + protected function setUp(): void { $this->configProvider = new ConfigProvider(); } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index ea3cfe02..43a052ae 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -27,7 +27,7 @@ class DomainServiceTest extends TestCase private DomainService $domainService; private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->domainService = new DomainService($this->em->reveal(), 'default.com'); diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 70063764..d40fce56 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -31,7 +31,7 @@ class NotFoundRedirectHandlerTest extends TestCase private ObjectProphecy $next; private ServerRequestInterface $req; - public function setUp(): void + protected function setUp(): void { $this->redirectOptions = new NotFoundRedirectOptions(); $this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class); diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index dcf42b54..12865171 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -21,7 +21,7 @@ class NotFoundTemplateHandlerTest extends TestCase private NotFoundTemplateHandler $handler; private bool $readFileCalled; - public function setUp(): void + protected function setUp(): void { $this->readFileCalled = false; $readFile = function (string $fileName): string { diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php index c928200e..b826802b 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -18,7 +18,7 @@ class CloseDbConnectionEventListenerDelegatorTest extends TestCase private CloseDbConnectionEventListenerDelegator $delegator; private ObjectProphecy $container; - public function setUp(): void + protected function setUp(): void { $this->container = $this->prophesize(ContainerInterface::class); $this->delegator = new CloseDbConnectionEventListenerDelegator(); diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index d0c7c374..7c4d74c8 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -20,7 +20,7 @@ class CloseDbConnectionEventListenerTest extends TestCase private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(ReopeningEntityManagerInterface::class); } diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php new file mode 100644 index 00000000..1c703f7f --- /dev/null +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -0,0 +1,54 @@ +locator = $this->prophesize(VisitLocatorInterface::class); + $this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class); + + $this->listener = new LocateUnlocatedVisits($this->locator->reveal(), $this->visitToLocation->reveal()); + } + + /** @test */ + public function locatorIsCalledWhenInvoked(): void + { + ($this->listener)(new GeoLiteDbCreated()); + $this->locator->locateUnlocatedVisits($this->listener)->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function visitToLocationHelperIsCalledToGeolocateVisits(): void + { + $visit = Visit::forBasePath(Visitor::emptyInstance()); + $location = Location::emptyInstance(); + + $resolve = $this->visitToLocation->resolveVisitLocation($visit)->willReturn($location); + + $result = $this->listener->geolocateVisit($visit); + + self::assertSame($location, $result); + $resolve->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 406e8146..5cf243d0 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -36,7 +36,7 @@ class LocateVisitTest extends TestCase private ObjectProphecy $dbUpdater; private ObjectProphecy $eventDispatcher; - public function setUp(): void + protected function setUp(): void { $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); @@ -193,7 +193,7 @@ class LocateVisitTest extends TestCase { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new UrlVisited('123', $originalIpAddress); + $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 1ce29d0d..0d8d9cfa 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -31,7 +31,7 @@ class NotifyVisitToMercureTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $logger; - public function setUp(): void + protected function setUp(): void { $this->helper = $this->prophesize(PublishingHelperInterface::class); $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 56324e40..4234a188 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -38,7 +38,7 @@ class NotifyVisitToWebHooksTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $logger; - public function setUp(): void + protected function setUp(): void { $this->httpClient = $this->prophesize(ClientInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); @@ -165,7 +165,7 @@ class NotifyVisitToWebHooksTest extends TestCase ['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits], ), new ShortUrlDataTransformer(new ShortUrlStringifier([])), - new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']), + new AppOptions('Shlink', '1.2.3'), ); } } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index e4b616e8..5638b0fe 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -21,7 +21,7 @@ class PublishingUpdatesGeneratorTest extends TestCase { private PublishingUpdatesGenerator $generator; - public function setUp(): void + protected function setUp(): void { $this->generator = new PublishingUpdatesGenerator( new ShortUrlDataTransformer(new ShortUrlStringifier([])), diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php index 9cf44977..5365fe0e 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -27,12 +27,10 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase { use ProphecyTrait; - private NotifyNewShortUrlToRabbitMq $listener; private ObjectProphecy $helper; private ObjectProphecy $updatesGenerator; private ObjectProphecy $em; private ObjectProphecy $logger; - private RabbitMqOptions $options; protected function setUp(): void { @@ -40,23 +38,12 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); - $this->options = new RabbitMqOptions(['enabled' => true]); - - $this->listener = new NotifyNewShortUrlToRabbitMq( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - $this->options, - ); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - $this->options->enabled = false; - - ($this->listener)(new ShortUrlCreated('123')); + ($this->listener(false))(new ShortUrlCreated('123')); $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -74,7 +61,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase ['shortUrlId' => $shortUrlId, 'name' => 'RabbitMQ'], ); - ($this->listener)(new ShortUrlCreated($shortUrlId)); + ($this->listener())(new ShortUrlCreated($shortUrlId)); $find->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalledOnce(); @@ -92,7 +79,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $update, ); - ($this->listener)(new ShortUrlCreated($shortUrlId)); + ($this->listener())(new ShortUrlCreated($shortUrlId)); $find->shouldHaveBeenCalledOnce(); $generateUpdate->shouldHaveBeenCalledOnce(); @@ -114,7 +101,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase ); $publish = $this->helper->publishUpdate($update)->willThrow($e); - ($this->listener)(new ShortUrlCreated($shortUrlId)); + ($this->listener())(new ShortUrlCreated($shortUrlId)); $this->logger->debug( 'Error while trying to notify {name} with new short URL. {e}', @@ -131,4 +118,15 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase yield [new Exception('Exception Error')]; yield [new DomainException('DomainException Error')]; } + + private function listener(bool $enabled = true): NotifyNewShortUrlToRabbitMq + { + return new NotifyNewShortUrlToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new RabbitMqOptions($enabled), + ); + } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 05ee7568..59f9c26a 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -35,12 +35,10 @@ class NotifyVisitToRabbitMqTest extends TestCase { use ProphecyTrait; - private NotifyVisitToRabbitMq $listener; private ObjectProphecy $helper; private ObjectProphecy $updatesGenerator; private ObjectProphecy $em; private ObjectProphecy $logger; - private RabbitMqOptions $options; protected function setUp(): void { @@ -48,24 +46,12 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); - $this->options = new RabbitMqOptions(['enabled' => true, 'legacy_visits_publishing' => false]); - - $this->listener = new NotifyVisitToRabbitMq( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - new OrphanVisitDataTransformer(), - $this->options, - ); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - $this->options->enabled = false; - - ($this->listener)(new VisitLocated('123')); + ($this->listener(new RabbitMqOptions(enabled: false)))(new VisitLocated('123')); $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -83,7 +69,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ['visitId' => $visitId, 'name' => 'RabbitMQ'], ); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener())(new VisitLocated($visitId)); $findVisit->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalledOnce(); @@ -105,7 +91,7 @@ class NotifyVisitToRabbitMqTest extends TestCase )->shouldBeCalledOnce(); }); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener())(new VisitLocated($visitId)); $findVisit->shouldHaveBeenCalledOnce(); $this->helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes( @@ -144,7 +130,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ); $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener())(new VisitLocated($visitId)); $this->logger->debug( 'Error while trying to notify {name} with new visit. {e}', @@ -172,13 +158,11 @@ class NotifyVisitToRabbitMqTest extends TestCase callable $assert, callable $setup, ): void { - $this->options->legacyVisitsPublishing = $legacy; - $visitId = '123'; $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $setup($this->updatesGenerator); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener(new RabbitMqOptions(true, $legacy)))(new VisitLocated($visitId)); $findVisit->shouldHaveBeenCalledOnce(); $assert($this->helper, $this->updatesGenerator); @@ -247,4 +231,16 @@ class NotifyVisitToRabbitMqTest extends TestCase }, ]; } + + private function listener(?RabbitMqOptions $options = null): NotifyVisitToRabbitMq + { + return new NotifyVisitToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new OrphanVisitDataTransformer(), + $options ?? new RabbitMqOptions(enabled: true, legacyVisitsPublishing: false), + ); + } } diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index 178a142f..9ce20801 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -8,11 +8,16 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use RuntimeException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; +use Shlinkio\Shlink\Core\EventDispatcher\Event\GeoLiteDbCreated; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; +use function Functional\map; + class UpdateGeoLiteDbTest extends TestCase { use ProphecyTrait; @@ -20,13 +25,19 @@ class UpdateGeoLiteDbTest extends TestCase private UpdateGeoLiteDb $listener; private ObjectProphecy $dbUpdater; private ObjectProphecy $logger; + private ObjectProphecy $eventDispatcher; protected function setUp(): void { $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->listener = new UpdateGeoLiteDb($this->dbUpdater->reveal(), $this->logger->reveal()); + $this->listener = new UpdateGeoLiteDb( + $this->dbUpdater->reveal(), + $this->logger->reveal(), + $this->eventDispatcher->reveal(), + ); } /** @test */ @@ -42,6 +53,7 @@ class UpdateGeoLiteDbTest extends TestCase $checkDbUpdate->shouldHaveBeenCalledOnce(); $logError->shouldHaveBeenCalledOnce(); $this->logger->notice(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @@ -51,9 +63,11 @@ class UpdateGeoLiteDbTest extends TestCase public function noticeMessageIsPrintedWhenFirstCallbackIsInvoked(bool $oldDbExists, string $expectedMessage): void { $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($oldDbExists): void { + function (array $args) use ($oldDbExists): GeolocationResult { [$firstCallback] = $args; $firstCallback($oldDbExists); + + return GeolocationResult::DB_IS_UP_TO_DATE; }, ); $logNotice = $this->logger->notice($expectedMessage); @@ -63,6 +77,7 @@ class UpdateGeoLiteDbTest extends TestCase $checkDbUpdate->shouldHaveBeenCalledOnce(); $logNotice->shouldHaveBeenCalledOnce(); $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideFlags(): iterable @@ -82,13 +97,15 @@ class UpdateGeoLiteDbTest extends TestCase ?string $expectedMessage, ): void { $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($total, $downloaded, $oldDbExists): void { + function (array $args) use ($total, $downloaded, $oldDbExists): GeolocationResult { [, $secondCallback] = $args; // Invoke several times to ensure the log is printed only once $secondCallback($total, $downloaded, $oldDbExists); $secondCallback($total, $downloaded, $oldDbExists); $secondCallback($total, $downloaded, $oldDbExists); + + return GeolocationResult::DB_UPDATED; }, ); $logNotice = $this->logger->notice($expectedMessage ?? Argument::cetera()); @@ -102,6 +119,7 @@ class UpdateGeoLiteDbTest extends TestCase } $checkDbUpdate->shouldHaveBeenCalledOnce(); $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideDownloaded(): iterable @@ -115,4 +133,28 @@ class UpdateGeoLiteDbTest extends TestCase yield [100, 101, true, 'Finished updating GeoLite2 db file']; yield [100, 101, false, 'Finished downloading GeoLite2 db file']; } + + /** + * @test + * @dataProvider provideGeolocationResults + */ + public function dispatchesEventOnlyWhenDbFileHasBeenCreatedForTheFirstTime( + GeolocationResult $result, + int $expectedDispatches, + ): void { + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willReturn($result); + + ($this->listener)(); + + $checkDbUpdate->shouldHaveBeenCalledOnce(); + $this->eventDispatcher->dispatch(new GeoLiteDbCreated())->shouldHaveBeenCalledTimes($expectedDispatches); + } + + public function provideGeolocationResults(): iterable + { + return map(GeolocationResult::cases(), static fn (GeolocationResult $value) => [ + $value, + $value === GeolocationResult::DB_CREATED ? 1 : 0, + ]); + } } diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index b331bdc2..e658e55d 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -37,7 +37,7 @@ class DeleteShortUrlExceptionTest extends TestCase 'threshold' => $threshold, ], $e->getAdditionalData()); self::assertEquals('Cannot delete short URL', $e->getTitle()); - self::assertEquals('INVALID_SHORT_URL_DELETION', $e->getType()); + self::assertEquals('https://shlink.io/api/error/invalid-short-url-deletion', $e->getType()); self::assertEquals(422, $e->getStatus()); } diff --git a/module/Core/test/Exception/DomainNotFoundExceptionTest.php b/module/Core/test/Exception/DomainNotFoundExceptionTest.php index 5f2b9889..f2f5daba 100644 --- a/module/Core/test/Exception/DomainNotFoundExceptionTest.php +++ b/module/Core/test/Exception/DomainNotFoundExceptionTest.php @@ -21,7 +21,7 @@ class DomainNotFoundExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Domain not found', $e->getTitle()); - self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals('https://shlink.io/api/error/domain-not-found', $e->getType()); self::assertEquals(['id' => $id], $e->getAdditionalData()); self::assertEquals(404, $e->getStatus()); } @@ -36,7 +36,7 @@ class DomainNotFoundExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Domain not found', $e->getTitle()); - self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals('https://shlink.io/api/error/domain-not-found', $e->getType()); self::assertEquals(['authority' => $authority], $e->getAdditionalData()); self::assertEquals(404, $e->getStatus()); } diff --git a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php index 40ccd0ee..b064cf91 100644 --- a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php +++ b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php @@ -25,7 +25,7 @@ class ForbiddenTagOperationExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Forbidden tag operation', $e->getTitle()); - self::assertEquals('FORBIDDEN_OPERATION', $e->getType()); + self::assertEquals('https://shlink.io/api/error/forbidden-tag-operation', $e->getType()); self::assertEquals(403, $e->getStatus()); } diff --git a/module/Core/test/Exception/InvalidUrlExceptionTest.php b/module/Core/test/Exception/InvalidUrlExceptionTest.php index 5351c1b3..e9b0d75a 100644 --- a/module/Core/test/Exception/InvalidUrlExceptionTest.php +++ b/module/Core/test/Exception/InvalidUrlExceptionTest.php @@ -27,7 +27,7 @@ class InvalidUrlExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Invalid URL', $e->getTitle()); - self::assertEquals('INVALID_URL', $e->getType()); + self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType()); self::assertEquals(['url' => $url], $e->getAdditionalData()); self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); diff --git a/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php b/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php index b1487b69..2089daba 100644 --- a/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php +++ b/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php @@ -9,6 +9,7 @@ use LogicException; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Exception\RuntimeException; +use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType; use Throwable; class IpCannotBeLocatedExceptionTest extends TestCase @@ -22,6 +23,7 @@ class IpCannotBeLocatedExceptionTest extends TestCase self::assertEquals('Ignored visit with no IP address', $e->getMessage()); self::assertEquals(0, $e->getCode()); self::assertNull($e->getPrevious()); + self::assertEquals(UnlocatableIpType::EMPTY_ADDRESS, $e->type); } /** @test */ @@ -33,6 +35,7 @@ class IpCannotBeLocatedExceptionTest extends TestCase self::assertEquals('Ignored localhost address', $e->getMessage()); self::assertEquals(0, $e->getCode()); self::assertNull($e->getPrevious()); + self::assertEquals(UnlocatableIpType::LOCALHOST, $e->type); } /** @@ -47,6 +50,7 @@ class IpCannotBeLocatedExceptionTest extends TestCase self::assertEquals('An error occurred while locating IP', $e->getMessage()); self::assertEquals($prev->getCode(), $e->getCode()); self::assertSame($prev, $e->getPrevious()); + self::assertEquals(UnlocatableIpType::ERROR, $e->type); } public function provideErrors(): iterable diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index 6720f0f3..77a71df3 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -25,7 +25,7 @@ class NonUniqueSlugExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Invalid custom slug', $e->getTitle()); - self::assertEquals('INVALID_SLUG', $e->getType()); + self::assertEquals('https://shlink.io/api/error/non-unique-slug', $e->getType()); self::assertEquals(400, $e->getStatus()); self::assertEquals($expectedAdditional, $e->getAdditionalData()); } diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index e86a63cb..2818f350 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -29,7 +29,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Short URL not found', $e->getTitle()); - self::assertEquals('INVALID_SHORTCODE', $e->getType()); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $e->getType()); self::assertEquals(404, $e->getStatus()); self::assertEquals($expectedAdditional, $e->getAdditionalData()); } diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index 4427eb40..ba7dfa1d 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -23,7 +23,7 @@ class TagConflictExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Tag conflict', $e->getTitle()); - self::assertEquals('TAG_CONFLICT', $e->getType()); + self::assertEquals('https://shlink.io/api/error/tag-conflict', $e->getType()); self::assertEquals(['oldName' => $oldName, 'newName' => $newName], $e->getAdditionalData()); self::assertEquals(409, $e->getStatus()); } diff --git a/module/Core/test/Exception/TagNotFoundExceptionTest.php b/module/Core/test/Exception/TagNotFoundExceptionTest.php index ccd63788..f22463c2 100644 --- a/module/Core/test/Exception/TagNotFoundExceptionTest.php +++ b/module/Core/test/Exception/TagNotFoundExceptionTest.php @@ -21,7 +21,7 @@ class TagNotFoundExceptionTest extends TestCase self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); self::assertEquals('Tag not found', $e->getTitle()); - self::assertEquals('TAG_NOT_FOUND', $e->getType()); + self::assertEquals('https://shlink.io/api/error/tag-not-found', $e->getType()); self::assertEquals(['tag' => $tag], $e->getAdditionalData()); self::assertEquals(404, $e->getStatus()); } diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index 92a46a16..92c21157 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -82,11 +82,11 @@ class VisitorTest extends TestCase $this->generateRandomString(2000), $this->generateRandomString(2000), ); - $normalizedVisitor = $visitor->normalizeForTrackingOptions(new TrackingOptions([ - 'disableIpTracking' => true, - 'disableReferrerTracking' => true, - 'disableUaTracking' => true, - ])); + $normalizedVisitor = $visitor->normalizeForTrackingOptions(new TrackingOptions( + disableIpTracking: true, + disableReferrerTracking: true, + disableUaTracking: true, + )); self::assertNotSame($visitor, $normalizedVisitor); self::assertEmpty($normalizedVisitor->userAgent); diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index cd4d6193..87a6582f 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -31,7 +31,7 @@ class DeleteShortUrlServiceTest extends TestCase private ObjectProphecy $urlResolver; private string $shortCode; - public function setUp(): void + protected function setUp(): void { $shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())), @@ -102,9 +102,9 @@ class DeleteShortUrlServiceTest extends TestCase private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService { - return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions([ - 'visitsThreshold' => $visitsThreshold, - 'checkVisitsThreshold' => $checkVisitsThreshold, - ]), $this->urlResolver->reveal()); + return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions( + $visitsThreshold, + $checkVisitsThreshold, + ), $this->urlResolver->reveal()); } } diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index bdccfa3f..d2c3bda5 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -32,7 +32,7 @@ class ShortUrlResolverTest extends TestCase private ShortUrlResolver $urlResolver; private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->urlResolver = new ShortUrlResolver($this->em->reveal()); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 90000423..a042dd1f 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -34,7 +34,7 @@ class ShortUrlServiceTest extends TestCase private ObjectProphecy $urlResolver; private ObjectProphecy $titleResolutionHelper; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->em->persist(Argument::any())->willReturn(null); diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index fbe9b1c4..86a057e5 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -30,7 +30,7 @@ class UrlShortenerTest extends TestCase private ObjectProphecy $shortCodeHelper; private ObjectProphecy $eventDispatcher; - public function setUp(): void + protected function setUp(): void { $this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class); $this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->willReturnArgument(); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index 829d77ea..97b35f2b 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -16,7 +16,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase protected function setUp(): void { - $trackingOptions = new TrackingOptions(['disable_track_param' => 'foobar']); + $trackingOptions = new TrackingOptions(disableTrackParam: 'foobar'); $this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions); } diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 4099faea..3cd2adef 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -34,12 +34,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase { use ProphecyTrait; - private ExtraPathRedirectMiddleware $middleware; private ObjectProphecy $resolver; private ObjectProphecy $requestTracker; private ObjectProphecy $redirectionBuilder; private ObjectProphecy $redirectResponseHelper; - private UrlShortenerOptions $options; private ObjectProphecy $handler; protected function setUp(): void @@ -48,16 +46,6 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); $this->redirectionBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); $this->redirectResponseHelper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->options = new UrlShortenerOptions(['append_extra_path' => true]); - - $this->middleware = new ExtraPathRedirectMiddleware( - $this->resolver->reveal(), - $this->requestTracker->reveal(), - $this->redirectionBuilder->reveal(), - $this->redirectResponseHelper->reveal(), - $this->options, - ); - $this->handler = $this->prophesize(RequestHandlerInterface::class); $this->handler->handle(Argument::cetera())->willReturn(new RedirectResponse('')); } @@ -71,10 +59,12 @@ class ExtraPathRedirectMiddlewareTest extends TestCase bool $multiSegmentEnabled, ServerRequestInterface $request, ): void { - $this->options->appendExtraPath = $appendExtraPath; - $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $options = new UrlShortenerOptions( + appendExtraPath: $appendExtraPath, + multiSegmentSlugsEnabled: $multiSegmentEnabled, + ); - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware($options)->process($request, $this->handler->reveal()); $this->handler->handle($request)->shouldHaveBeenCalledOnce(); $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -123,7 +113,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase bool $multiSegmentEnabled, int $expectedResolveCalls, ): void { - $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); @@ -135,7 +125,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase Argument::that(fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode')), )->willThrow(ShortUrlNotFoundException::class); - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware($options)->process($request, $this->handler->reveal()); $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -152,7 +142,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase int $expectedResolveCalls, ?string $expectedExtraPath, ): void { - $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); @@ -181,7 +171,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase new RedirectResponse(''), ); - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware($options)->process($request, $this->handler->reveal()); $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $buildLongUrl->shouldHaveBeenCalledOnce(); @@ -194,4 +184,15 @@ class ExtraPathRedirectMiddlewareTest extends TestCase yield [false, 1, '/bar/baz']; yield [true, 3, null]; } + + private function middleware(?UrlShortenerOptions $options = null): ExtraPathRedirectMiddleware + { + return new ExtraPathRedirectMiddleware( + $this->resolver->reveal(), + $this->requestTracker->reveal(), + $this->redirectionBuilder->reveal(), + $this->redirectResponseHelper->reveal(), + $options ?? new UrlShortenerOptions(appendExtraPath: true), + ); + } } diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 2675b04a..2abc08c3 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -22,7 +22,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase private ObjectProphecy $repo; - public function setUp(): void + protected function setUp(): void { $this->repo = $this->prophesize(ShortUrlRepositoryInterface::class); } diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 9aaf9495..39bb1b3f 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -25,7 +25,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase private PersistenceShortUrlRelationResolver $resolver; private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->em->getEventManager()->willReturn(new EventManager()); diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index 483cb67a..669fdd6e 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -13,7 +13,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase { private SimpleShortUrlRelationResolver $resolver; - public function setUp(): void + protected function setUp(): void { $this->resolver = new SimpleShortUrlRelationResolver(); } diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index 81c0d203..b48cd839 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -17,7 +17,7 @@ class ShortUrlDataTransformerTest extends TestCase { private ShortUrlDataTransformer $transformer; - public function setUp(): void + protected function setUp(): void { $this->transformer = new ShortUrlDataTransformer(new ShortUrlStringifier([])); } diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 8c301f0f..d3e1b841 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -33,7 +33,7 @@ class TagServiceTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $repo; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->repo = $this->prophesize(TagRepository::class); diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index 651d4bc7..fc2b89a2 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -11,15 +11,6 @@ use Shlinkio\Shlink\Core\Util\RedirectResponseHelper; class RedirectResponseHelperTest extends TestCase { - private RedirectResponseHelper $helper; - private RedirectOptions $shortenerOpts; - - protected function setUp(): void - { - $this->shortenerOpts = new RedirectOptions(); - $this->helper = new RedirectResponseHelper($this->shortenerOpts); - } - /** * @test * @dataProvider provideRedirectConfigs @@ -30,10 +21,9 @@ class RedirectResponseHelperTest extends TestCase int $expectedStatus, ?string $expectedCacheControl, ): void { - $this->shortenerOpts->redirectStatusCode = $configuredStatus; - $this->shortenerOpts->redirectCacheLifetime = $configuredLifetime; + $options = new RedirectOptions($configuredStatus, $configuredLifetime); - $response = $this->helper->buildRedirectResponse('destination'); + $response = $this->helper($options)->buildRedirectResponse('destination'); self::assertInstanceOf(RedirectResponse::class, $response); self::assertEquals($expectedStatus, $response->getStatusCode()); @@ -52,4 +42,9 @@ class RedirectResponseHelperTest extends TestCase yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30']; yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30']; } + + private function helper(?RedirectOptions $options = null): RedirectResponseHelper + { + return new RedirectResponseHelper($options ?? new RedirectOptions()); + } } diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 8aba6598..cc13bd2c 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -23,15 +23,11 @@ class UrlValidatorTest extends TestCase { use ProphecyTrait; - private UrlValidator $urlValidator; private ObjectProphecy $httpClient; - private UrlShortenerOptions $options; - public function setUp(): void + protected function setUp(): void { $this->httpClient = $this->prophesize(ClientInterface::class); - $this->options = new UrlShortenerOptions(['validate_url' => true]); - $this->urlValidator = new UrlValidator($this->httpClient->reveal(), $this->options); } /** @test */ @@ -42,7 +38,7 @@ class UrlValidatorTest extends TestCase $request->shouldBeCalledOnce(); $this->expectException(InvalidUrlException::class); - $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar', true); + $this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true); } /** @test */ @@ -65,7 +61,7 @@ class UrlValidatorTest extends TestCase }), )->willReturn(new Response()); - $this->urlValidator->validateUrl($expectedUrl, true); + $this->urlValidator()->validateUrl($expectedUrl, true); $request->shouldHaveBeenCalledOnce(); } @@ -75,7 +71,7 @@ class UrlValidatorTest extends TestCase { $request = $this->httpClient->request(Argument::cetera())->willReturn(new Response()); - $this->urlValidator->validateUrl('', false); + $this->urlValidator()->validateUrl('', false); $request->shouldNotHaveBeenCalled(); } @@ -84,9 +80,8 @@ class UrlValidatorTest extends TestCase public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void { $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); - $this->options->autoResolveTitles = true; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); + $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); self::assertNull($result); $request->shouldHaveBeenCalledOnce(); @@ -96,9 +91,8 @@ class UrlValidatorTest extends TestCase public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void { $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); - $this->options->autoResolveTitles = false; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); + $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); self::assertNull($result); $request->shouldNotHaveBeenCalled(); @@ -110,9 +104,8 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request(RequestMethodInterface::METHOD_HEAD, Argument::cetera())->willReturn( $this->respWithTitle(), ); - $this->options->autoResolveTitles = false; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); $request->shouldHaveBeenCalledOnce(); @@ -124,9 +117,8 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( $this->respWithTitle(), ); - $this->options->autoResolveTitles = true; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertEquals('Resolved "title"', $result); $request->shouldHaveBeenCalledOnce(); @@ -138,9 +130,8 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream']), ); - $this->options->autoResolveTitles = true; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); $request->shouldHaveBeenCalledOnce(); @@ -152,9 +143,8 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( new Response($this->createStreamWithContent('No title'), 200, ['Content-Type' => 'text/html']), ); - $this->options->autoResolveTitles = true; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); $request->shouldHaveBeenCalledOnce(); @@ -174,4 +164,12 @@ class UrlValidatorTest extends TestCase return $body; } + + public function urlValidator(bool $autoResolveTitles = false): UrlValidator + { + return new UrlValidator( + $this->httpClient->reveal(), + new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles), + ); + } } diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php index 144087ad..4634004f 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -38,10 +38,10 @@ class RequestTrackerTest extends TestCase $this->requestTracker = new RequestTracker( $this->visitsTracker->reveal(), - new TrackingOptions([ - 'disable_track_param' => 'foobar', - 'disable_tracking_from' => ['80.90.100.110', '192.168.10.0/24', '1.2.*.*'], - ]), + new TrackingOptions( + disableTrackParam: 'foobar', + disableTrackingFrom: ['80.90.100.110', '192.168.10.0/24', '1.2.*.*'], + ), ); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index 5c51b848..21908be8 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -38,7 +38,7 @@ class VisitLocatorTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $repo; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManager::class); $this->repo = $this->prophesize(VisitRepositoryInterface::class); @@ -129,7 +129,7 @@ class VisitLocatorTest extends TestCase public function geolocateVisit(Visit $visit): Location { throw $this->isNonLocatableAddress - ? new IpCannotBeLocatedException('Cannot be located') + ? IpCannotBeLocatedException::forEmptyAddress() : IpCannotBeLocatedException::forError(new Exception('')); } diff --git a/module/Core/test/Visit/VisitToLocationHelperTest.php b/module/Core/test/Visit/VisitToLocationHelperTest.php new file mode 100644 index 00000000..ee22272f --- /dev/null +++ b/module/Core/test/Visit/VisitToLocationHelperTest.php @@ -0,0 +1,66 @@ +ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); + $this->helper = new VisitToLocationHelper($this->ipLocationResolver->reveal()); + } + + /** + * @test + * @dataProvider provideNonLocatableVisits + */ + public function throwsExpectedErrorForNonLocatableVisit( + Visit $visit, + IpCannotBeLocatedException $expectedException, + ): void { + $this->expectExceptionObject($expectedException); + $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotBeCalled(); + + $this->helper->resolveVisitLocation($visit); + } + + public function provideNonLocatableVisits(): iterable + { + yield [Visit::forBasePath(Visitor::emptyInstance()), IpCannotBeLocatedException::forEmptyAddress()]; + yield [ + Visit::forBasePath(new Visitor('foo', 'bar', IpAddress::LOCALHOST, '')), + IpCannotBeLocatedException::forLocalhost(), + ]; + } + + /** @test */ + public function throwsGenericErrorWhenResolvingIpFails(): void + { + $e = new WrongIpException(''); + + $this->expectExceptionObject(IpCannotBeLocatedException::forError($e)); + $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow($e) + ->shouldBeCalledOnce(); + + $this->helper->resolveVisitLocation(Visit::forBasePath(new Visitor('foo', 'bar', '1.2.3.4', ''))); + } +} diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 42c821bb..47288cb3 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -43,7 +43,7 @@ class VisitsStatsHelperTest extends TestCase private VisitsStatsHelper $helper; private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); $this->helper = new VisitsStatsHelper($this->em->reveal()); diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 904f92d1..72028543 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -24,16 +24,11 @@ class VisitsTrackerTest extends TestCase private VisitsTracker $visitsTracker; private ObjectProphecy $em; private ObjectProphecy $eventDispatcher; - private TrackingOptions $options; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManager::class); - $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->options = new TrackingOptions(); - - $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options); } /** @@ -45,7 +40,7 @@ class VisitsTrackerTest extends TestCase $persist = $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->will(function (): void { }); - $this->visitsTracker->{$method}(...$args); + $this->visitsTracker()->{$method}(...$args); $persist->shouldHaveBeenCalledOnce(); $this->em->flush()->shouldHaveBeenCalledOnce(); @@ -58,9 +53,7 @@ class VisitsTrackerTest extends TestCase */ public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void { - $this->options->disableTracking = true; - - $this->visitsTracker->{$method}(...$args); + $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -81,9 +74,7 @@ class VisitsTrackerTest extends TestCase */ public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void { - $this->options->trackOrphanVisits = false; - - $this->visitsTracker->{$method}(Visitor::emptyInstance()); + $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::emptyInstance()); $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -96,4 +87,13 @@ class VisitsTrackerTest extends TestCase yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit']; yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit']; } + + private function visitsTracker(?TrackingOptions $options = null): VisitsTracker + { + return new VisitsTracker( + $this->em->reveal(), + $this->eventDispatcher->reveal(), + $options ?? new TrackingOptions(), + ); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 189180b0..a70cb7f1 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -53,6 +53,7 @@ return [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class, + Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class => InvokableFactory::class, ], ], diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 63716e74..1e0b041b 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -15,7 +15,8 @@ use function Shlinkio\Shlink\Core\determineTableName; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); - $builder->setTable(determineTableName('api_keys', $emConfig)); + $builder->setTable(determineTableName('api_keys', $emConfig)) + ->setCustomRepositoryClass(ApiKey\Repository\ApiKeyRepository::class); $builder->createField('id', Types::BIGINT) ->makePrimaryKey() diff --git a/module/Rest/config/initial-api-key.config.php b/module/Rest/config/initial-api-key.config.php new file mode 100644 index 00000000..a44f877f --- /dev/null +++ b/module/Rest/config/initial-api-key.config.php @@ -0,0 +1,26 @@ + PHP_SAPI !== 'cli' ? null : EnvVars::INITIAL_API_KEY->loadFromEnv(), + + 'dependencies' => [ + 'delegators' => [ + Application::class => [ + ApiKey\InitialApiKeyDelegator::class, + ], + ], + ], + +]; diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index 462eb345..f3bfea98 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -42,7 +42,7 @@ class HealthAction extends AbstractRestAction $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; return new JsonResponse([ 'status' => $connected ? self::STATUS_PASS : self::STATUS_FAIL, - 'version' => $this->options->getVersion(), + 'version' => $this->options->version, 'links' => [ 'about' => 'https://shlink.io', 'project' => 'https://github.com/shlinkio/shlink', diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 376c6bec..46fff970 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -23,7 +23,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); - $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled(); + $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; return ShortUrlMeta::fromRawData($payload); } diff --git a/module/Rest/src/ApiKey/InitialApiKeyDelegator.php b/module/Rest/src/ApiKey/InitialApiKeyDelegator.php new file mode 100644 index 00000000..a5aa9d33 --- /dev/null +++ b/module/Rest/src/ApiKey/InitialApiKeyDelegator.php @@ -0,0 +1,31 @@ +get('config')['initial_api_key'] ?? null; + if (! empty($initialApiKey)) { + $this->createInitialApiKey($initialApiKey, $container); + } + + return $callback(); + } + + private function createInitialApiKey(string $initialApiKey, ContainerInterface $container): void + { + /** @var ApiKeyRepositoryInterface $repo */ + $repo = $container->get(EntityManager::class)->getRepository(ApiKey::class); + $repo->createInitialApiKey($initialApiKey); + } +} diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php new file mode 100644 index 00000000..ec49145e --- /dev/null +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -0,0 +1,32 @@ +getEntityManager(); + $em->wrapInTransaction(function () use ($apiKey, $em): void { + // Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates + // Because of that we check if at least one result exists + $firstResult = $em->createQueryBuilder()->select('a.id') + ->from(ApiKey::class, 'a') + ->setMaxResults(1) + ->getQuery() + ->setLockMode(LockMode::PESSIMISTIC_WRITE) + ->getOneOrNullResult(); + + if ($firstResult === null) { + $em->persist(ApiKey::fromKey($apiKey)); + $em->flush(); + } + }); + } +} diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php new file mode 100644 index 00000000..f5beb3e9 --- /dev/null +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -0,0 +1,16 @@ +key = Uuid::uuid4()->toString(); - $this->expirationDate = $expirationDate; - $this->name = $name; + $this->key = $key ?? Uuid::uuid4()->toString(); $this->enabled = true; $this->roles = new ArrayCollection(); } @@ -44,7 +42,10 @@ class ApiKey extends AbstractEntity public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($meta->name, $meta->expirationDate); + $apiKey = self::create(); + $apiKey->name = $meta->name; + $apiKey->expirationDate = $meta->expirationDate; + foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } @@ -52,6 +53,11 @@ class ApiKey extends AbstractEntity return $apiKey; } + public static function fromKey(string $key): self + { + return new self($key); + } + public function getExpirationDate(): ?Chronos { return $this->expirationDate; diff --git a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php new file mode 100644 index 00000000..685d3795 --- /dev/null +++ b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php @@ -0,0 +1,97 @@ +getMessage(), $e->getCode(), $e); + } + + public static function fromProblemDetails(ProblemDetailsExceptionInterface $e): self + { + return new self($e); + } + + public function getStatus(): int + { + return $this->e->getStatus(); + } + + public function getType(): string + { + return $this->remapType($this->e->getType()); + } + + public function getTitle(): string + { + return $this->e->getTitle(); + } + + public function getDetail(): string + { + return $this->e->getDetail(); + } + + public function getAdditionalData(): array + { + return $this->e->getAdditionalData(); + } + + public function toArray(): array + { + return $this->remapTypeInArray($this->e->toArray()); + } + + public function jsonSerialize(): array + { + return $this->remapTypeInArray($this->e->jsonSerialize()); + } + + private function remapTypeInArray(array $wrappedArray): array + { + if (! isset($wrappedArray['type'])) { + return $wrappedArray; + } + + return [...$wrappedArray, 'type' => $this->remapType($wrappedArray['type'])]; + } + + private function remapType(string $wrappedType): string + { + $lastSegment = last(explode('/', $wrappedType)); + return match ($lastSegment) { + ValidationException::ERROR_CODE => 'INVALID_ARGUMENT', + DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION', + DomainNotFoundException::ERROR_CODE => 'DOMAIN_NOT_FOUND', + ForbiddenTagOperationException::ERROR_CODE => 'FORBIDDEN_OPERATION', + InvalidUrlException::ERROR_CODE => 'INVALID_URL', + NonUniqueSlugException::ERROR_CODE => 'INVALID_SLUG', + ShortUrlNotFoundException::ERROR_CODE => 'INVALID_SHORTCODE', + TagConflictException::ERROR_CODE => 'TAG_CONFLICT', + TagNotFoundException::ERROR_CODE => 'TAG_NOT_FOUND', + MercureException::ERROR_CODE => 'MERCURE_NOT_CONFIGURED', + MissingAuthenticationException::ERROR_CODE => 'INVALID_AUTHORIZATION', + VerifyAuthenticationException::ERROR_CODE => 'INVALID_API_KEY', + default => $wrappedType, + }; + } +} diff --git a/module/Rest/src/Exception/MercureException.php b/module/Rest/src/Exception/MercureException.php index 9435cb54..7e47b519 100644 --- a/module/Rest/src/Exception/MercureException.php +++ b/module/Rest/src/Exception/MercureException.php @@ -8,12 +8,14 @@ use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function Shlinkio\Shlink\Core\toProblemDetailsType; + class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface { use CommonProblemDetailsExceptionTrait; private const TITLE = 'Mercure integration not configured'; - private const TYPE = 'MERCURE_NOT_CONFIGURED'; + public const ERROR_CODE = 'mercure-not-configured'; public static function mercureNotConfigured(): self { @@ -21,7 +23,7 @@ class MercureException extends RuntimeException implements ProblemDetailsExcepti $e->detail = $e->getMessage(); $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED; return $e; diff --git a/module/Rest/src/Exception/MissingAuthenticationException.php b/module/Rest/src/Exception/MissingAuthenticationException.php index 99dbc0df..3fd2e2c6 100644 --- a/module/Rest/src/Exception/MissingAuthenticationException.php +++ b/module/Rest/src/Exception/MissingAuthenticationException.php @@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function implode; +use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; class MissingAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface @@ -16,7 +17,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid authorization'; - private const TYPE = 'INVALID_AUTHORIZATION'; + public const ERROR_CODE = 'missing-authentication'; public static function forHeaders(array $expectedHeaders): self { @@ -43,7 +44,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem $e->detail = $message; $e->title = self::TITLE; - $e->type = self::TYPE; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; return $e; diff --git a/module/Rest/src/Exception/VerifyAuthenticationException.php b/module/Rest/src/Exception/VerifyAuthenticationException.php index 702230ff..25f1b050 100644 --- a/module/Rest/src/Exception/VerifyAuthenticationException.php +++ b/module/Rest/src/Exception/VerifyAuthenticationException.php @@ -8,17 +8,21 @@ use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function Shlinkio\Shlink\Core\toProblemDetailsType; + class VerifyAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface { use CommonProblemDetailsExceptionTrait; + public const ERROR_CODE = 'invalid-api-key'; + public static function forInvalidApiKey(): self { $e = new self('Provided API key does not exist or is invalid.'); $e->detail = $e->getMessage(); $e->title = 'Invalid API key'; - $e->type = 'INVALID_API_KEY'; + $e->type = toProblemDetailsType(self::ERROR_CODE); $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; return $e; diff --git a/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php b/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php new file mode 100644 index 00000000..c099ad70 --- /dev/null +++ b/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php @@ -0,0 +1,30 @@ +handle($request); + } catch (ProblemDetailsExceptionInterface $e) { + $version = $request->getAttribute('version') ?? '2'; + throw version_compare($version, '3', '>=') + ? $e + : BackwardsCompatibleProblemDetailsException::fromProblemDetails($e); + } + } +} diff --git a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php index 8eb98153..73a6ac69 100644 --- a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php @@ -11,14 +11,14 @@ use Psr\Http\Server\RequestHandlerInterface; class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface { - public function __construct(private string $defaultDomain) + public function __construct(private readonly string $defaultDomain) { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { /** @var array $body */ - $body = $request->getParsedBody(); + $body = $request->getParsedBody() ?? []; $request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams())) ->withParsedBody($this->sanitizeDomainFromPayload($body)); diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 2fe529a3..26d271f0 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -60,6 +60,25 @@ class CreateShortUrlTest extends ApiTestCase } } + /** + * @test + * @dataProvider provideDuplicatedSlugApiVersions + */ + public function expectedTypeIsReturnedForConflictingSlugBasedOnApiVersion( + string $version, + string $expectedType, + ): void { + [, $payload] = $this->createShortUrl(['customSlug' => 'custom'], version: $version); + self::assertEquals($expectedType, $payload['type']); + } + + public function provideDuplicatedSlugApiVersions(): iterable + { + yield ['1', 'INVALID_SLUG']; + yield ['2', 'INVALID_SLUG']; + yield ['3', 'https://shlink.io/api/error/non-unique-slug']; + } + /** * @test * @dataProvider provideTags @@ -226,15 +245,15 @@ class CreateShortUrlTest extends ApiTestCase * @test * @dataProvider provideInvalidUrls */ - public function failsToCreateShortUrlWithInvalidLongUrl(string $url): void + public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void { $expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url); - [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true]); + [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version); self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_URL', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid URL', $payload['title']); self::assertEquals($url, $payload['url']); @@ -242,23 +261,37 @@ class CreateShortUrlTest extends ApiTestCase public function provideInvalidUrls(): iterable { - yield 'empty URL' => ['']; - yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com']; + yield 'empty URL' => ['', '2', 'INVALID_URL']; + yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; + yield 'API version 3' => ['', '3', 'https://shlink.io/api/error/invalid-url']; } - /** @test */ - public function failsToCreateShortUrlWithoutLongUrl(): void + /** + * @test + * @dataProvider provideInvalidArgumentApiVersions + */ + public function failsToCreateShortUrlWithoutLongUrl(string $version, string $expectedType): void { - $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => []]); + $resp = $this->callApiWithKey( + self::METHOD_POST, + sprintf('/rest/v%s/short-urls', $version), + [RequestOptions::JSON => []], + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals('Provided data is not valid', $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } + public function provideInvalidArgumentApiVersions(): iterable + { + yield ['2', 'INVALID_ARGUMENT']; + yield ['3', 'https://shlink.io/api/error/invalid-data']; + } + /** @test */ public function defaultDomainIsDroppedIfProvided(): void { @@ -332,12 +365,17 @@ class CreateShortUrlTest extends ApiTestCase /** * @return array{int $statusCode, array $payload} */ - private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array + private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key', string $version = '2'): array { if (! isset($body['longUrl'])) { $body['longUrl'] = 'https://app.shlink.io'; } - $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body], $apiKey); + $resp = $this->callApiWithKey( + self::METHOD_POST, + sprintf('/rest/v%s/short-urls', $version), + [RequestOptions::JSON => $body], + $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); return [$resp->getStatusCode(), $payload]; diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 5cac3dbd..f8ba6ef1 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -7,6 +7,8 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use function sprintf; + class DeleteShortUrlTest extends ApiTestCase { use NotFoundUrlHelpersTrait; @@ -33,6 +35,28 @@ class DeleteShortUrlTest extends ApiTestCase self::assertEquals($domain, $payload['domain'] ?? null); } + /** + * @test + * @dataProvider provideApiVersions + */ + public function expectedTypeIsReturnedBasedOnApiVersion(string $version, string $expectedType): void + { + $resp = $this->callApiWithKey( + self::METHOD_DELETE, + sprintf('/rest/v%s/short-urls/invalid-short-code', $version), + ); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals($expectedType, $payload['type']); + } + + public function provideApiVersions(): iterable + { + yield ['1', 'INVALID_SHORTCODE']; + yield ['2', 'INVALID_SHORTCODE']; + yield ['3', 'https://shlink.io/api/error/short-url-not-found']; + } + /** @test */ public function properShortUrlIsDeletedWhenDomainIsProvided(): void { diff --git a/module/Rest/test-api/Action/DeleteTagsTest.php b/module/Rest/test-api/Action/DeleteTagsTest.php index ca175b69..c81d7906 100644 --- a/module/Rest/test-api/Action/DeleteTagsTest.php +++ b/module/Rest/test-api/Action/DeleteTagsTest.php @@ -7,29 +7,32 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; +use function sprintf; + class DeleteTagsTest extends ApiTestCase { /** * @test * @dataProvider provideNonAdminApiKeys */ - public function anErrorIsReturnedWithNonAdminApiKeys(string $apiKey): void + public function anErrorIsReturnedWithNonAdminApiKeys(string $apiKey, string $version, string $expectedType): void { - $resp = $this->callApiWithKey(self::METHOD_DELETE, '/tags', [ + $resp = $this->callApiWithKey(self::METHOD_DELETE, sprintf('/rest/v%s/tags', $version), [ RequestOptions::QUERY => ['tags' => ['foo']], ], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); - self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals('You are not allowed to delete tags', $payload['detail']); self::assertEquals('Forbidden tag operation', $payload['title']); } public function provideNonAdminApiKeys(): iterable { - yield 'author' => ['author_api_key']; - yield 'domain' => ['domain_api_key']; + yield 'author' => ['author_api_key', '2', 'FORBIDDEN_OPERATION']; + yield 'domain' => ['domain_api_key', '2', 'FORBIDDEN_OPERATION']; + yield 'version 3' => ['domain_api_key', '3', 'https://shlink.io/api/error/forbidden-tag-operation']; } } diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php index b6e29a12..c6c31ebb 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -65,4 +65,23 @@ class DomainVisitsTest extends ApiTestCase yield 'domain API key with not-owned valid domain' => ['domain_api_key', 'this_domain_is_detached.com']; yield 'author API key with valid domain not used in URLs' => ['author_api_key', 'this_domain_is_detached.com']; } + + /** + * @test + * @dataProvider provideApiVersions + */ + public function expectedNotFoundTypeIsReturnedForApiVersion(string $version, string $expectedType): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/rest/v%s/domains/invalid.com/visits', $version)); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals($expectedType, $payload['type']); + } + + public function provideApiVersions(): iterable + { + yield ['1', 'DOMAIN_NOT_FOUND']; + yield ['2', 'DOMAIN_NOT_FOUND']; + yield ['3', 'https://shlink.io/api/error/domain-not-found']; + } } diff --git a/module/Rest/test-api/Action/UpdateTagTest.php b/module/Rest/test-api/Action/UpdateTagTest.php index 262789d7..414e7670 100644 --- a/module/Rest/test-api/Action/UpdateTagTest.php +++ b/module/Rest/test-api/Action/UpdateTagTest.php @@ -7,6 +7,8 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; +use function sprintf; + class UpdateTagTest extends ApiTestCase { /** @@ -34,12 +36,15 @@ class UpdateTagTest extends ApiTestCase yield [['newName' => 'foo']]; } - /** @test */ - public function tryingToRenameInvalidTagReturnsNotFound(): void + /** + * @test + * @dataProvider provideTagNotFoundApiVersions + */ + public function tryingToRenameInvalidTagReturnsNotFound(string $version, string $expectedType): void { $expectedDetail = 'Tag with name "invalid_tag" could not be found'; - $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [ + $resp = $this->callApiWithKey(self::METHOD_PUT, sprintf('/rest/v%s/tags', $version), [RequestOptions::JSON => [ 'oldName' => 'invalid_tag', 'newName' => 'foo', ]]); @@ -47,17 +52,27 @@ class UpdateTagTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('TAG_NOT_FOUND', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Tag not found', $payload['title']); } - /** @test */ - public function errorIsThrownWhenTryingToRenameTagToAnotherTagName(): void + public function provideTagNotFoundApiVersions(): iterable + { + yield 'version 1' => ['1', 'TAG_NOT_FOUND']; + yield 'version 2' => ['2', 'TAG_NOT_FOUND']; + yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-not-found']; + } + + /** + * @test + * @dataProvider provideTagConflictsApiVersions + */ + public function errorIsThrownWhenTryingToRenameTagToAnotherTagName(string $version, string $expectedType): void { $expectedDetail = 'You cannot rename tag foo to bar, because it already exists'; - $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [ + $resp = $this->callApiWithKey(self::METHOD_PUT, sprintf('/rest/v%s/tags', $version), [RequestOptions::JSON => [ 'oldName' => 'foo', 'newName' => 'bar', ]]); @@ -65,11 +80,18 @@ class UpdateTagTest extends ApiTestCase self::assertEquals(self::STATUS_CONFLICT, $resp->getStatusCode()); self::assertEquals(self::STATUS_CONFLICT, $payload['status']); - self::assertEquals('TAG_CONFLICT', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Tag conflict', $payload['title']); } + public function provideTagConflictsApiVersions(): iterable + { + yield 'version 1' => ['1', 'TAG_CONFLICT']; + yield 'version 2' => ['2', 'TAG_CONFLICT']; + yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-conflict']; + } + /** @test */ public function tagIsProperlyRenamedWhenRenamingToItself(): void { diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index ef6d1781..54797bb4 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -25,7 +25,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface { $manager->persist($this->buildApiKey('valid_api_key', true)); $manager->persist($this->buildApiKey('disabled_api_key', false)); - $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay())); + $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay()->startOfDay())); $authorApiKey = $this->buildApiKey('author_api_key', true); $authorApiKey->registerRole(RoleDefinition::forAuthoredShortUrls()); diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index 61dbd2c5..51128079 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -6,32 +6,47 @@ namespace ShlinkioApiTest\Shlink\Rest\Middleware; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; +use function sprintf; + class AuthenticationTest extends ApiTestCase { - /** @test */ - public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void + /** + * @test + * @dataProvider provideApiVersions + */ + public function authorizationErrorIsReturnedIfNoApiKeyIsSent(string $version, string $expectedType): void { $expectedDetail = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided'; - $resp = $this->callApi(self::METHOD_GET, '/short-urls'); + $resp = $this->callApi(self::METHOD_GET, sprintf('/rest/v%s/short-urls', $version)); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - self::assertEquals('INVALID_AUTHORIZATION', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid authorization', $payload['title']); } + public function provideApiVersions(): iterable + { + yield 'version 1' => ['1', 'INVALID_AUTHORIZATION']; + yield 'version 2' => ['2', 'INVALID_AUTHORIZATION']; + yield 'version 3' => ['3', 'https://shlink.io/api/error/missing-authentication']; + } + /** * @test * @dataProvider provideInvalidApiKeys */ - public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(string $apiKey): void - { + public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid( + string $apiKey, + string $version, + string $expectedType, + ): void { $expectedDetail = 'Provided API key does not exist or is invalid.'; - $resp = $this->callApi(self::METHOD_GET, '/short-urls', [ + $resp = $this->callApi(self::METHOD_GET, sprintf('/rest/v%s/short-urls', $version), [ 'headers' => [ 'X-Api-Key' => $apiKey, ], @@ -40,15 +55,16 @@ class AuthenticationTest extends ApiTestCase self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - self::assertEquals('INVALID_API_KEY', $payload['type']); + self::assertEquals($expectedType, $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid API key', $payload['title']); } public function provideInvalidApiKeys(): iterable { - yield 'key which does not exist' => ['invalid']; - yield 'key which is expired' => ['expired_api_key']; - yield 'key which is disabled' => ['disabled_api_key']; + yield 'key which does not exist' => ['invalid', '2', 'INVALID_API_KEY']; + yield 'key which is expired' => ['expired_api_key', '2', 'INVALID_API_KEY']; + yield 'key which is disabled' => ['disabled_api_key', '2', 'INVALID_API_KEY']; + yield 'version 3' => ['disabled_api_key', '3', 'https://shlink.io/api/error/invalid-api-key']; } } diff --git a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php new file mode 100644 index 00000000..ae6ab0a0 --- /dev/null +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -0,0 +1,31 @@ +repo = $this->getEntityManager()->getRepository(ApiKey::class); + } + + /** @test */ + public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void + { + self::assertCount(0, $this->repo->findAll()); + $this->repo->createInitialApiKey('initial_value'); + self::assertCount(1, $this->repo->findAll()); + self::assertCount(1, $this->repo->findBy(['key' => 'initial_value'])); + $this->repo->createInitialApiKey('another_one'); + self::assertCount(1, $this->repo->findAll()); + self::assertCount(0, $this->repo->findBy(['key' => 'another_one'])); + } +} diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index bc852b34..a6903b46 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -25,7 +25,7 @@ class ListDomainsActionTest extends TestCase private ObjectProphecy $domainService; private NotFoundRedirectOptions $options; - public function setUp(): void + protected function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); $this->options = new NotFoundRedirectOptions(); diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 05212fe7..51509047 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -55,7 +55,7 @@ class DomainRedirectsRequestTest extends TestCase yield 'some values' => [['domain' => 'foo', 'regular404Redirect' => 'bar'], null, 'foo', null, 'bar', null]; yield 'fallbacks' => [ ['domain' => 'domain', 'baseUrlRedirect' => 'bar'], - new NotFoundRedirectOptions(['regular404' => 'fallback', 'invalidShortUrl' => 'fallback2']), + new NotFoundRedirectOptions(invalidShortUrl: 'fallback2', regular404: 'fallback'), 'domain', 'bar', 'fallback', @@ -63,7 +63,7 @@ class DomainRedirectsRequestTest extends TestCase ]; yield 'fallback ignored' => [ ['domain' => 'domain', 'regular404Redirect' => 'bar', 'invalidShortUrlRedirect' => null], - new NotFoundRedirectOptions(['regular404' => 'fallback', 'invalidShortUrl' => 'fallback2']), + new NotFoundRedirectOptions(invalidShortUrl: 'fallback2', regular404: 'fallback'), 'domain', null, 'bar', diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index a233087a..461152a4 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -25,7 +25,7 @@ class HealthActionTest extends TestCase private HealthAction $action; private ObjectProphecy $conn; - public function setUp(): void + protected function setUp(): void { $this->conn = $this->prophesize(Connection::class); $this->conn->executeQuery(Argument::cetera())->willReturn($this->prophesize(Result::class)->reveal()); @@ -36,7 +36,7 @@ class HealthActionTest extends TestCase $em = $this->prophesize(EntityManagerInterface::class); $em->getConnection()->willReturn($this->conn->reveal()); - $this->action = new HealthAction($em->reveal(), new AppOptions(['version' => '1.2.3'])); + $this->action = new HealthAction($em->reveal(), new AppOptions(version: '1.2.3')); } /** @test */ diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index 33083c79..e586a641 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -21,7 +21,7 @@ class MercureInfoActionTest extends TestCase private ObjectProphecy $provider; - public function setUp(): void + protected function setUp(): void { $this->provider = $this->prophesize(JwtProviderInterface::class); } diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 206b016f..eb0d8622 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -29,7 +29,7 @@ class CreateShortUrlActionTest extends TestCase private ObjectProphecy $urlShortener; private ObjectProphecy $transformer; - public function setUp(): void + protected function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortener::class); $this->transformer = $this->prophesize(DataTransformerInterface::class); diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 9705cd59..ae49cf4b 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -20,7 +20,7 @@ class DeleteShortUrlActionTest extends TestCase private DeleteShortUrlAction $action; private ObjectProphecy $service; - public function setUp(): void + protected function setUp(): void { $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); $this->action = new DeleteShortUrlAction($this->service->reveal()); diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index e1f434df..4d09042d 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -24,7 +24,7 @@ class EditShortUrlActionTest extends TestCase private EditShortUrlAction $action; private ObjectProphecy $shortUrlService; - public function setUp(): void + protected function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), new ShortUrlDataTransformer( diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 59876b55..8b295358 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -26,7 +26,7 @@ class ListShortUrlsActionTest extends TestCase private ListShortUrlsAction $action; private ObjectProphecy $service; - public function setUp(): void + protected function setUp(): void { $this->service = $this->prophesize(ShortUrlService::class); diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 19422d9d..78898f7a 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -23,7 +23,7 @@ class ResolveShortUrlActionTest extends TestCase private ResolveShortUrlAction $action; private ObjectProphecy $urlResolver; - public function setUp(): void + protected function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), new ShortUrlDataTransformer( diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index e3fd3e10..f62a5da6 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -25,7 +25,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase private ObjectProphecy $urlShortener; private ObjectProphecy $transformer; - public function setUp(): void + protected function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortenerInterface::class); $this->transformer = $this->prophesize(DataTransformerInterface::class); diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 4812649d..457507e8 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -20,7 +20,7 @@ class DeleteTagsActionTest extends TestCase private DeleteTagsAction $action; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new DeleteTagsAction($this->tagService->reveal()); diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 123e4945..3da8594c 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -28,7 +28,7 @@ class ListTagsActionTest extends TestCase private ListTagsAction $action; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new ListTagsAction($this->tagService->reveal()); diff --git a/module/Rest/test/Action/Tag/TagsStatsActionTest.php b/module/Rest/test/Action/Tag/TagsStatsActionTest.php index 2cb3ad64..44e6afb0 100644 --- a/module/Rest/test/Action/Tag/TagsStatsActionTest.php +++ b/module/Rest/test/Action/Tag/TagsStatsActionTest.php @@ -27,7 +27,7 @@ class TagsStatsActionTest extends TestCase private TagsStatsAction $action; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new TagsStatsAction($this->tagService->reveal()); diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index d7b398db..a3bce658 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -24,7 +24,7 @@ class UpdateTagActionTest extends TestCase private UpdateTagAction $action; private ObjectProphecy $tagService; - public function setUp(): void + protected function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new UpdateTagAction($this->tagService->reveal()); diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index 829b820b..d5f94250 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -21,7 +21,7 @@ class GlobalVisitsActionTest extends TestCase private GlobalVisitsAction $action; private ObjectProphecy $helper; - public function setUp(): void + protected function setUp(): void { $this->helper = $this->prophesize(VisitsStatsHelperInterface::class); $this->action = new GlobalVisitsAction($this->helper->reveal()); diff --git a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php index 5b3487f0..60224bef 100644 --- a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php @@ -24,7 +24,7 @@ class NonOrphanVisitsActionTest extends TestCase private NonOrphanVisitsAction $action; private ObjectProphecy $visitsHelper; - public function setUp(): void + protected function setUp(): void { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $this->action = new NonOrphanVisitsAction($this->visitsHelper->reveal()); diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 299c42d1..d9f248e6 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -27,7 +27,7 @@ class ShortUrlVisitsActionTest extends TestCase private ShortUrlVisitsAction $action; private ObjectProphecy $visitsHelper; - public function setUp(): void + protected function setUp(): void { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $this->action = new ShortUrlVisitsAction($this->visitsHelper->reveal()); diff --git a/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php b/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php new file mode 100644 index 00000000..1db53b80 --- /dev/null +++ b/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php @@ -0,0 +1,62 @@ +delegator = new InitialApiKeyDelegator(); + $this->container = $this->prophesize(ContainerInterface::class); + } + + /** + * @test + * @dataProvider provideConfigs + */ + public function apiKeyIsInitializedWhenAppropriate(array $config, int $expectedCalls): void + { + $app = $this->prophesize(Application::class)->reveal(); + $apiKeyRepo = $this->prophesize(ApiKeyRepositoryInterface::class); + $em = $this->prophesize(EntityManagerInterface::class); + + $getConfig = $this->container->get('config')->willReturn($config); + $getRepo = $em->getRepository(ApiKey::class)->willReturn($apiKeyRepo->reveal()); + $getEm = $this->container->get(EntityManager::class)->willReturn($em->reveal()); + + $result = ($this->delegator)($this->container->reveal(), '', fn () => $app); + + self::assertSame($result, $app); + $getConfig->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledTimes($expectedCalls); + $getEm->shouldHaveBeenCalledTimes($expectedCalls); + $apiKeyRepo->createInitialApiKey(Argument::any())->shouldHaveBeenCalledTimes($expectedCalls); + } + + public function provideConfigs(): iterable + { + yield 'no api key' => [[], 0]; + yield 'null api key' => [['initial_api_key' => null], 0]; + yield 'empty api key' => [['initial_api_key' => ''], 0]; + yield 'valid api key' => [['initial_api_key' => 'the_initial_key'], 1]; + } +} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index a3f7d0c9..1f7044f9 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -12,7 +12,7 @@ class ConfigProviderTest extends TestCase { private ConfigProvider $configProvider; - public function setUp(): void + protected function setUp(): void { $this->configProvider = new ConfigProvider(); } @@ -22,10 +22,11 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(4, $config); + self::assertCount(5, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('entity_manager', $config); + self::assertArrayHasKey('initial_api_key', $config); self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } @@ -48,10 +49,10 @@ class ConfigProviderTest extends TestCase ['path' => '/health'], ], [ - ['path' => '/rest/v{version:1|2}/foo'], - ['path' => '/rest/v{version:1|2}/bar'], - ['path' => '/rest/v{version:1|2}/baz/foo'], - ['path' => '/rest/v{version:1|2}/health'], + ['path' => '/rest/v{version:1|2|3}/foo'], + ['path' => '/rest/v{version:1|2|3}/bar'], + ['path' => '/rest/v{version:1|2|3}/baz/foo'], + ['path' => '/rest/v{version:1|2|3}/health'], ['path' => '/rest/health', 'name' => ConfigProvider::UNVERSIONED_HEALTH_ENDPOINT_NAME], ], ]; @@ -62,9 +63,9 @@ class ConfigProviderTest extends TestCase ['path' => '/baz/foo'], ], [ - ['path' => '/rest/v{version:1|2}/foo'], - ['path' => '/rest/v{version:1|2}/bar'], - ['path' => '/rest/v{version:1|2}/baz/foo'], + ['path' => '/rest/v{version:1|2|3}/foo'], + ['path' => '/rest/v{version:1|2|3}/bar'], + ['path' => '/rest/v{version:1|2|3}/baz/foo'], ], ]; } diff --git a/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php b/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php new file mode 100644 index 00000000..c63cee71 --- /dev/null +++ b/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php @@ -0,0 +1,114 @@ +type; + } + + public function getTitle(): string + { + return 'title'; + } + + public function getDetail(): string + { + return 'detail'; + } + + public function getAdditionalData(): array + { + return []; + } + + public function toArray(): array + { + return ['type' => $this->type]; + } + + public function jsonSerialize(): array + { + return ['type' => $this->type]; + } + }; + $e = BackwardsCompatibleProblemDetailsException::fromProblemDetails($original); + + self::assertEquals($e->getType(), $expectedType); + self::assertEquals($e->toArray(), ['type' => $expectedType]); + self::assertEquals($e->jsonSerialize(), ['type' => $expectedType]); + + self::assertEquals($original->getTitle(), $e->getTitle()); + self::assertEquals($original->getDetail(), $e->getDetail()); + self::assertEquals($original->getAdditionalData(), $e->getAdditionalData()); + + if ($expectSameType) { + self::assertEquals($original->getType(), $e->getType()); + self::assertEquals($original->toArray(), $e->toArray()); + self::assertEquals($original->jsonSerialize(), $e->jsonSerialize()); + } else { + self::assertNotEquals($original->getType(), $e->getType()); + self::assertNotEquals($original->toArray(), $e->toArray()); + self::assertNotEquals($original->jsonSerialize(), $e->jsonSerialize()); + } + } + + public function provideTypes(): iterable + { + yield ['foo', 'foo', true]; + yield ['bar', 'bar', true]; + yield [ValidationException::ERROR_CODE, 'INVALID_ARGUMENT']; + yield [DeleteShortUrlException::ERROR_CODE, 'INVALID_SHORT_URL_DELETION']; + yield [DomainNotFoundException::ERROR_CODE, 'DOMAIN_NOT_FOUND']; + yield [ForbiddenTagOperationException::ERROR_CODE, 'FORBIDDEN_OPERATION']; + yield [InvalidUrlException::ERROR_CODE, 'INVALID_URL']; + yield [NonUniqueSlugException::ERROR_CODE, 'INVALID_SLUG']; + yield [ShortUrlNotFoundException::ERROR_CODE, 'INVALID_SHORTCODE']; + yield [TagConflictException::ERROR_CODE, 'TAG_CONFLICT']; + yield [TagNotFoundException::ERROR_CODE, 'TAG_NOT_FOUND']; + yield [MercureException::ERROR_CODE, 'MERCURE_NOT_CONFIGURED']; + yield [MissingAuthenticationException::ERROR_CODE, 'INVALID_AUTHORIZATION']; + yield [VerifyAuthenticationException::ERROR_CODE, 'INVALID_API_KEY']; + } +} diff --git a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php index 5d80ca17..ab79ba2f 100644 --- a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php +++ b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php @@ -65,7 +65,7 @@ class MissingAuthenticationExceptionTest extends TestCase private function assertCommonExceptionShape(MissingAuthenticationException $e): void { self::assertEquals('Invalid authorization', $e->getTitle()); - self::assertEquals('INVALID_AUTHORIZATION', $e->getType()); + self::assertEquals('https://shlink.io/api/error/missing-authentication', $e->getType()); self::assertEquals(401, $e->getStatus()); } } diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index c915098a..eef78ab7 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -35,7 +35,7 @@ class AuthenticationMiddlewareTest extends TestCase private ObjectProphecy $apiKeyService; private ObjectProphecy $handler; - public function setUp(): void + protected function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->middleware = new AuthenticationMiddleware( diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index 04c9478d..f254197e 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -23,7 +23,7 @@ class BodyParserMiddlewareTest extends TestCase private BodyParserMiddleware $middleware; - public function setUp(): void + protected function setUp(): void { $this->middleware = new BodyParserMiddleware(); } diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index acdc9600..286652bf 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -20,7 +20,7 @@ class CrossDomainMiddlewareTest extends TestCase private CrossDomainMiddleware $middleware; private ObjectProphecy $handler; - public function setUp(): void + protected function setUp(): void { $this->middleware = new CrossDomainMiddleware(['max_age' => 1000]); $this->handler = $this->prophesize(RequestHandlerInterface::class); diff --git a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php index 4928f2ef..b2093461 100644 --- a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -15,7 +15,7 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase { private EmptyResponseImplicitOptionsMiddlewareFactory $factory; - public function setUp(): void + protected function setUp(): void { $this->factory = new EmptyResponseImplicitOptionsMiddlewareFactory(); } diff --git a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php new file mode 100644 index 00000000..00dddb2f --- /dev/null +++ b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php @@ -0,0 +1,76 @@ +handler = new BackwardsCompatibleProblemDetailsHandler(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function expectedExceptionIsThrownBasedOnTheRequestVersion( + ServerRequestInterface $request, + Throwable $thrownException, + string $expectedException, + ): void { + $handler = $this->prophesize(RequestHandlerInterface::class); + $handle = $handler->handle($request)->willThrow($thrownException); + + $this->expectException($expectedException); + $handle->shouldBeCalledOnce(); + + $this->handler->process($request, $handler->reveal()); + } + + public function provideExceptions(): iterable + { + $baseRequest = ServerRequestFactory::fromGlobals(); + + yield 'no version' => [ + $baseRequest, + ValidationException::fromArray([]), + BackwardsCompatibleProblemDetailsException::class, + ]; + yield 'version 1' => [ + $baseRequest->withAttribute('version', '1'), + ValidationException::fromArray([]), + BackwardsCompatibleProblemDetailsException::class, + ]; + yield 'version 2' => [ + $baseRequest->withAttribute('version', '2'), + ValidationException::fromArray([]), + BackwardsCompatibleProblemDetailsException::class, + ]; + yield 'version 3' => [ + $baseRequest->withAttribute('version', '3'), + ValidationException::fromArray([]), + ValidationException::class, + ]; + yield 'version 4' => [ + $baseRequest->withAttribute('version', '3'), + ValidationException::fromArray([]), + ValidationException::class, + ]; + } +} diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index dc4733ff..b77d79a9 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -22,7 +22,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase private CreateShortUrlContentNegotiationMiddleware $middleware; private ObjectProphecy $requestHandler; - public function setUp(): void + protected function setUp(): void { $this->middleware = new CreateShortUrlContentNegotiationMiddleware(); $this->requestHandler = $this->prophesize(RequestHandlerInterface::class); diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php index e10e9f73..2aef77b7 100644 --- a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -23,7 +23,7 @@ class DefaultShortCodesLengthMiddlewareTest extends TestCase private DefaultShortCodesLengthMiddleware $middleware; private ObjectProphecy $handler; - public function setUp(): void + protected function setUp(): void { $this->handler = $this->prophesize(RequestHandlerInterface::class); $this->middleware = new DefaultShortCodesLengthMiddleware(8); diff --git a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php index 24f3aecd..9418a16a 100644 --- a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php @@ -22,7 +22,7 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase private DropDefaultDomainFromRequestMiddleware $middleware; private ObjectProphecy $next; - public function setUp(): void + protected function setUp(): void { $this->next = $this->prophesize(RequestHandlerInterface::class); $this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in'); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index aba79036..f384a45a 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -25,7 +25,7 @@ class ApiKeyServiceTest extends TestCase private ApiKeyService $service; private ObjectProphecy $em; - public function setUp(): void + protected function setUp(): void { $this->em = $this->prophesize(EntityManager::class); $this->service = new ApiKeyService($this->em->reveal()); diff --git a/phpunit-cli.xml b/phpunit-cli.xml new file mode 100644 index 00000000..49ba781e --- /dev/null +++ b/phpunit-cli.xml @@ -0,0 +1,20 @@ + + + + + ./module/*/test-cli + + + + + + ./module/CLI/src + ./module/Core/src + + +