Compare commits

..

3 Commits

Author SHA1 Message Date
Alejandro Celaya
8888e33ccc Merge pull request #2052 from acelaya-forks/feature/fix-geolite-update-backport
Fix infinite GeoLite2 downloads - v3.x backport
2024-03-09 09:53:00 +01:00
Alejandro Celaya
250f9f2d89 Update changelog 2024-03-09 09:37:28 +01:00
Alejandro Celaya
9fd864df0b Make sure GeoLite2 db file is always read from the filesystem befor etrying to operate on it 2024-03-09 09:36:29 +01:00
310 changed files with 5461 additions and 4847 deletions

View File

@@ -19,6 +19,7 @@ indocker
docker-* docker-*
phpstan.neon phpstan.neon
php*xml* php*xml*
infection*
**/test* **/test*
build* build*
**/.* **/.*

View File

@@ -20,8 +20,10 @@ body:
options: options:
- Self-hosted Apache - Self-hosted Apache
- Self-hosted nginx - Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner - Self-hosted RoadRunner
- Docker image - Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary) - Other (explain in summary)
- type: dropdown - type: dropdown
validations: validations:

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: ['acelaya'] github: ['acelaya']
custom: ['https://slnk.to/donate'] custom: ['https://acel.me/donate']

View File

@@ -22,8 +22,10 @@ body:
options: options:
- Self-hosted Apache - Self-hosted Apache
- Self-hosted nginx - Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner - Self-hosted RoadRunner
- Docker image - Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary) - Other (explain in summary)
- type: dropdown - type: dropdown
validations: validations:
@@ -58,10 +60,5 @@ body:
validations: validations:
required: true required: true
attributes: attributes:
label: Minimum steps to reproduce label: How to reproduce
value: | value: '<!-- Provide steps to reproduce the bug. -->'
<!--
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
Avoid things like "Create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause and the bug may be closed as "not reproducible".
If you can provide a simple docker compose config, that's even better.
-->

View File

@@ -12,6 +12,7 @@ inputs:
php-extensions: php-extensions:
description: 'The PHP extensions to install' description: 'The PHP extensions to install'
required: false required: false
default: ''
extensions-cache-key: extensions-cache-key:
description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled' description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled'
required: true required: true
@@ -20,7 +21,6 @@ runs:
using: composite using: composite
steps: steps:
- name: Setup cache environment - name: Setup cache environment
if: ${{ inputs.php-extensions }}
id: extcache id: extcache
uses: shivammathur/cache-extensions@v1 uses: shivammathur/cache-extensions@v1
with: with:
@@ -28,8 +28,7 @@ runs:
extensions: ${{ inputs.php-extensions }} extensions: ${{ inputs.php-extensions }}
key: ${{ inputs.extensions-cache-key }} key: ${{ inputs.extensions-cache-key }}
- name: Cache extensions - name: Cache extensions
if: ${{ inputs.php-extensions }} uses: actions/cache@v3
uses: actions/cache@v4
with: with:
path: ${{ steps.extcache.outputs.dir }} path: ${{ steps.extcache.outputs.dir }}
key: ${{ steps.extcache.outputs.key }} key: ${{ steps.extcache.outputs.key }}
@@ -44,5 +43,5 @@ runs:
ini-values: pcov.directory=module ini-values: pcov.directory=module
- name: Install dependencies - name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }} if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
shell: bash shell: bash

View File

@@ -14,6 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
php-version: ['8.2', '8.3'] php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env: env:
LC_ALL: C LC_ALL: C
steps: steps:
@@ -27,7 +28,7 @@ jobs:
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: pdo_sqlsrv-5.12.0 php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.1
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database - name: Create test database
if: ${{ inputs.platform == 'ms' }} if: ${{ inputs.platform == 'ms' }}

46
.github/workflows/ci-mutation-tests.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
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.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v4
with:
name: coverage-${{ inputs.test-group }}
path: build
- name: Resolve infection args
id: infection_args
run: echo "args=--logger-github=false" >> $GITHUB_OUTPUT
# 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 "args=--logger-github=false" >> $GITHUB_OUTPUT
# else
# echo "args=--logger-github=false --git-diff-lines --git-diff-base=develop" >> $GITHUB_OUTPUT
# 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 }}

View File

@@ -14,8 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
php-version: ['8.2', '8.3'] php-version: ['8.2', '8.3']
env: continue-on-error: ${{ matrix.php-version == '8.3' }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Start postgres database server - name: Start postgres database server
@@ -27,10 +26,8 @@ jobs:
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- name: Download RoadRunner binary
if: ${{ inputs.test-group == 'api' }}
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci - run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' }} if: ${{ matrix.php-version == '8.2' }}

View File

@@ -8,6 +8,7 @@ on:
- '*.md' - '*.md'
- '*.xml' - '*.xml'
- '*.yml*' - '*.yml*'
- '*.json5'
- '*.neon' - '*.neon'
push: push:
branches: branches:
@@ -20,6 +21,7 @@ on:
- '*.md' - '*.md'
- '*.xml' - '*.xml'
- '*.yml*' - '*.yml*'
- '*.json5'
- '*.neon' - '*.neon'
jobs: jobs:
@@ -34,6 +36,7 @@ jobs:
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }} - run: composer ${{ matrix.command }}
@@ -47,25 +50,89 @@ jobs:
with: with:
test-group: cli test-group: cli
api-tests: openswoole-api-tests:
uses: './.github/workflows/ci-tests.yml' uses: './.github/workflows/ci-tests.yml'
with: with:
test-group: api test-group: api
db-tests: roadrunner-api-tests:
runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
- uses: actions/checkout@v4
- 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
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
- run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:api:rr
sqlite-db-tests:
uses: './.github/workflows/ci-db-tests.yml' uses: './.github/workflows/ci-db-tests.yml'
with: with:
platform: ${{ matrix.platform }} platform: 'sqlite:ci'
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:
- 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: upload-coverage:
needs: needs:
- unit-tests - unit-tests
- api-tests - openswoole-api-tests
- cli-tests - cli-tests
- db-tests - sqlite-db-tests
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -74,10 +141,11 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use PHP - name: Use PHP
uses: './.github/actions/ci-setup' uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
extensions-cache-key: tests-extensions-${{ matrix.php-version }} coverage: pcov
ini-values: pcov.directory=module
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
path: build path: build
@@ -85,14 +153,19 @@ jobs:
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.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-api/coverage-api.cov build/coverage-api.cov
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: vendor/bin/phpcov merge build --clover build/clover.xml - run: wget https://phar.phpunit.de/phpcov-9.0.0.phar
- run: php phpcov-9.0.0.phar merge build --clover build/clover.xml
- name: Publish coverage - name: Publish coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v1
with: with:
file: ./build/clover.xml file: ./build/clover.xml
delete-artifacts: delete-artifacts:
needs: needs:
- unit-mutation-tests
- db-mutation-tests
- api-mutation-tests
- cli-mutation-tests
- upload-coverage - upload-coverage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:

View File

@@ -15,6 +15,13 @@ jobs:
- runtime: 'rr' - runtime: 'rr'
tag-suffix: 'roadrunner' tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64' platforms: 'linux/arm64/v8,linux/amd64'
- runtime: 'openswoole'
tag-suffix: 'openswoole'
platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'non-root'
platforms: 'linux/arm64/v8,linux/amd64'
user-id: '1001'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit secrets: inherit
with: with:
@@ -24,3 +31,4 @@ jobs:
tags-suffix: ${{ matrix.tag-suffix }} tags-suffix: ${{ matrix.tag-suffix }}
extra-build-args: | extra-build-args: |
SHLINK_RUNTIME=${{ matrix.runtime }} SHLINK_RUNTIME=${{ matrix.runtime }}
SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }}

View File

@@ -11,17 +11,22 @@ jobs:
strategy: strategy:
matrix: matrix:
php-version: ['8.2', '8.3'] php-version: ['8.2', '8.3']
swoole: ['yes', 'no']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no' install-deps: 'no'
- run: ./build.sh ${GITHUB_REF#refs/tags/v} - 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@v4 - uses: actions/upload-artifact@v4
with: with:
name: dist-files-${{ matrix.php-version }} name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
path: build path: build
publish: publish:

View File

@@ -20,6 +20,7 @@ jobs:
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline - run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }} - run: mkdir ${{ steps.determine_version.outputs.version }}

3
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.idea .idea
bin/rr bin/rr
.pid config/roadrunner/.pid
build build
!docker/build !docker/build
composer.lock composer.lock
@@ -15,4 +15,3 @@ docs/mercure.html
docker-compose.override.yml docker-compose.override.yml
.phpunit.result.cache .phpunit.result.cache
docs/swagger/swagger-inlined.json docs/swagger/swagger-inlined.json
phpcov*

View File

@@ -4,25 +4,7 @@ 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). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [4.0.3] - 2024-03-15 ## [3.7.4] - 2024-03-09
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2058](https://github.com/shlinkio/shlink/issues/2058) Fix DB credentials provided as env vars being casted to `int` if they include only numbers.
* [#2060](https://github.com/shlinkio/shlink/issues/2060) Fix error when trying to redirect to a non-http long URL.
## [4.0.2] - 2024-03-09
### Added ### Added
* *Nothing* * *Nothing*
@@ -39,76 +21,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#2021](https://github.com/shlinkio/shlink/issues/2021) Fix infinite GeoLite2 downloads. * [#2021](https://github.com/shlinkio/shlink/issues/2021) Fix infinite GeoLite2 downloads.
## [4.0.1] - 2024-03-08
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2041](https://github.com/shlinkio/shlink/issues/2041) Document missing `color` and `bgColor` params for the QR code route in the OAS docs.
* [#2043](https://github.com/shlinkio/shlink/issues/2043) Fix language redirect conditions matching too low quality accepted languages.
## [4.0.0] - 2024-03-03
### Added
* [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL.
Rules can be based on things like the presence of specific params, headers, locations, etc. This version ships with three initial rule condition types: device, query param and language.
* [#1902](https://github.com/shlinkio/shlink/issues/1902) Add dynamic redirects based on query parameters.
This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912).
* [#1915](https://github.com/shlinkio/shlink/issues/1915) Add dynamic redirects based on accept language.
This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912).
* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image.
* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type.
This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag.
* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo.
* [#1884](https://github.com/shlinkio/shlink/issues/1884) Allow a path prefix to be provided during short URL creation.
This can be useful to let Shlink generate partially random URLs, but with a known prefix.
Path prefixes are validated and filtered taking multi-segment slugs into consideration, which means slashes are replaced with dashes as long as multi-segment slugs are disabled.
### Changed
* [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware.
* [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package.
* [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3.
* [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default.
* [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0.
* [#2010](https://github.com/shlinkio/shlink/issues/2010) Update to Symfony 7.0 components.
* [#2016](https://github.com/shlinkio/shlink/issues/2016) Simplify and improve how code coverage is generated in API and CLI tests.
* [#1674](https://github.com/shlinkio/shlink/issues/1674) Database columns persisting long URLs have now `TEXT` type, which allows for much longer values.
### Deprecated
* *Nothing*
### Removed
* [#1908](https://github.com/shlinkio/shlink/issues/1908) Remove support for openswoole (and swoole).
### Fixed
* [#2000](https://github.com/shlinkio/shlink/issues/2000) Fix short URL creation/edition getting stuck when trying to resolve the title of a long URL which never returns a response.
## [3.7.3] - 2024-01-04 ## [3.7.3] - 2024-01-04
### Added ### Added
* *Nothing* * *Nothing*
### Changed ### Changed
* [#1968](https://github.com/shlinkio/shlink/issues/1968) Move migrations from `data` to `module/Core`. * [#1968](https://github.com/shlinkio/shlink/issues/1968) Move migrations from `data` to `module/Core`.
* *Nothing*
### Deprecated ### Deprecated
* *Nothing* * *Nothing*

View File

@@ -31,7 +31,7 @@ Then you will have to follow these steps:
* Run `./indocker bin/cli db:migrate` to get database migrations up to date. * Run `./indocker bin/cli db:migrate` to get database migrations up to date.
* Run `./indocker bin/cli api-key:generate` to get your first API key generated. * Run `./indocker bin/cli api-key:generate` to get your first API key generated.
Once you finish this, you will have the project exposed in ports `8800` through RoadRunner and `8000` through nginx+php-fpm. Once you finish this, you will have the project exposed in ports `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm.
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container. > Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
@@ -80,7 +80,7 @@ The purposes of every folder are:
* `data`: Common git-ignored assets, like logs, caches, lock files, GeoLite DB files, etc. It's the only location where Shlink may need to write at runtime. * `data`: Common git-ignored assets, like logs, caches, lock files, GeoLite DB files, etc. It's the only location where Shlink may need to write at runtime.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. * `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner. * `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner or openswoole.
## Project tests ## Project tests
@@ -96,7 +96,7 @@ In order to ensure stability and no regressions are introduced while developing
The project provides some tooling to run them against any of the supported database engines. The project provides some tooling to run them against any of the supported database engines.
* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner, and test it from the outside by interacting with the REST API. * **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner or openswoole, and test it from the outside by interacting with the REST API.
These are the best tests to catch regressions, and to verify everything behaves as expected. These are the best tests to catch regressions, and to verify everything behaves as expected.
@@ -124,6 +124,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. * 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 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, 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.
## Testing endpoints ## Testing endpoints

View File

@@ -1,12 +1,14 @@
FROM php:8.3-alpine3.19 as base FROM php:8.2-alpine3.17 as base
ARG SHLINK_VERSION=latest ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION} ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=rr ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ARG SHLINK_USER_ID='root'
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
ENV USER_ID '1001' ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.12.0 ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV LC_ALL 'C' ENV LC_ALL 'C'
@@ -24,8 +26,13 @@ RUN \
apk del .dev-deps && \ apk del .dev-deps && \
apk add --no-cache postgresql icu libzip libpng apk add --no-cache postgresql icu libzip libpng
# Install sqlsrv driver for x86_64 builds # Install openswoole and sqlsrv driver for x86_64 builds
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole ; \
fi; \
if [ $(uname -m) == "x86_64" ]; then \ if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
@@ -40,7 +47,14 @@ FROM base as builder
COPY . . COPY . .
COPY --from=composer:2 /usr/bin/composer ./composer.phar COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \ RUN apk add --no-cache git && \
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ # FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \
fi; \
php composer.phar clear-cache && \ php composer.phar clear-cache && \
rm -r docker composer.* && \ rm -r docker composer.* && \
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
@@ -50,7 +64,7 @@ RUN apk add --no-cache git && \
FROM base FROM base
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder --chown=${USER_ID} /etc/shlink . COPY --from=builder --chown=${SHLINK_USER_ID} /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 \ if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \ php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \
@@ -64,6 +78,6 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
USER ${USER_ID} USER ${SHLINK_USER_ID}
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2016-2024 Alejandro Celaya Copyright (c) 2016-2023 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -2,13 +2,12 @@
[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink)
[![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio) [![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain. A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
@@ -39,11 +38,12 @@ First, make sure the host where you are going to run shlink fulfills these requi
* PHP 8.2 or 8.3 * PHP 8.2 or 8.3
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use RoadRunner. * apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format. * xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance. * sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite. * MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`. * You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx).
### Download ### Download
@@ -53,7 +53,7 @@ In order to run Shlink, you will need a built version of the project. There are
The easiest way to install shlink is by using one of the pre-bundled distributable packages. The easiest way to install shlink is by using one of the pre-bundled distributable packages.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version. Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without openswoole integration.
Finally, decompress the file in the location of your choice. Finally, decompress the file in the location of your choice.

View File

@@ -1,55 +1,5 @@
# Upgrading # Upgrading
## From v3.x to v4.x
### General
* Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner.
* Dist files for swoole/openswoole are no longer published.
* Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms.
* When using RoadRunner, the amount of web workers, task workers and the port number can no longer be provided via config options. Use `WEB_WORKER_NUM`, `TASK_WORKER_NUM` and `PORT` env vars instead.
### Changes in URL shortener
* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead.
* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations.
If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option.
* Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option.
* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition.
* Device long URLs have been migrated to the new Dynamic rule-based redirects system and will continue to work as expected, but the API surface has changed.
If you use shlink-web-client and rely on this feature when creating/updating short URLs, **DO NOT UPDATE YET**. Support for dynamic rule-based redirects will be added to shlink-web-client soon, in v4.1.0
### Changes in REST API
* REST API v1/v2 now behave like v3. This only affects error codes, which are now proper URIs.
* `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_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`
* Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it.
* It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead.
* It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead.
* The `deviceLongUrls` are ignored when calling `POST /short-urls` or `PATCH /short-urls/{shortCode}`. These should now be configured as dynamic rule-based redirects via `POST /short-urls/{shortCode}/redirect-rules`.
### Changes in Docker image
* Since openswoole is no longer supported, there are no longer image tags suffixed with `openswoole`. You should migrate to the default or `roadrunner` ones.
* The `non-root` docker tag is no longer published, as all docker images are now running without super-user permissions.
* Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically.
This was not really needed in the docker image, as visits are located on the fly.
### Changes in integrations
* Credentials in redis URLs should now be URL-encoded, as they are unconditionally url-decoded before being used. Previously, it was possible to customize this behavior via `REDIS_DECODE_CREDENTIALS=true|false`.
* Providing redis URIs in the form of `tcp://password@6.6.6.6:6379` is no longer supported. If you want to provide password with no username, do `tcp://:password@6.6.6.6:6379` instead.
## From v2.x to v3.x ## From v2.x to v3.x
### Changes in REST API ### Changes in REST API

View File

@@ -2,7 +2,7 @@
export APP_ENV=test export APP_ENV=test
export TEST_ENV=api export TEST_ENV=api
export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently supported export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0
export DB_DRIVER="${DB_DRIVER:-"postgres"}" export DB_DRIVER="${DB_DRIVER:-"postgres"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
@@ -13,19 +13,26 @@ mkdir data/log/api-tests
touch $OUTPUT_LOGS touch $OUTPUT_LOGS
# Try to stop server just in case it hanged in last execution # Try to stop server just in case it hanged in last execution
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f -w . [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f
echo 'Starting server...' echo 'Starting server...'
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -w . -c=config/roadrunner/.rr.test.yml \ [ "$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.output="${PWD}/${OUTPUT_LOGS}" \
-o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \
-o=logs.channels.server.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 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 $* vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
TESTS_EXIT_CODE=$? testsExitCode=$?
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w . [ "$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 this script with the same code as the tests. If tests failed, this script has to fail
exit $TESTS_EXIT_CODE exit $testsExitCode

View File

@@ -1,15 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -e
if [ "$#" -lt 1 ]; then if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
echo "Usage:" >&2 echo "Usage:" >&2
echo " $0 {version}" >&2 echo " $0 {version} [--no-swoole]" >&2
exit 1 exit 1
fi fi
version=$1 version=$1
noSwoole=$2
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
distId="shlink${version}_php${phpVersion}_dist" # Openswoole is deprecated. Remove in v4.0.0
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole"
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
builtContent="./build/${distId}" builtContent="./build/${distId}"
projectdir=$(pwd) projectdir=$(pwd)
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer' [[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
@@ -28,8 +31,19 @@ cd "${builtContent}"
# Install dependencies # Install dependencies
echo "Installing dependencies with $composerBin..." echo "Installing dependencies with $composerBin..."
# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0
composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+"
${composerBin} self-update ${composerBin} self-update
${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction ${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
# Deprecated. Remove in Shlink v4.0.0
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
fi
# Delete development files # Delete development files
echo 'Deleting dev files...' echo 'Deleting dev files...'

View File

@@ -19,13 +19,13 @@
"ext-pdo": "*", "ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1", "akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^3.0.2", "cakephp/chronos": "^3.0.2",
"doctrine/dbal": "^4.0",
"doctrine/migrations": "^3.6", "doctrine/migrations": "^3.6",
"doctrine/orm": "^3.0", "doctrine/orm": "^2.16",
"endroid/qr-code": "^5.0", "endroid/qr-code": "^4.8",
"friendsofphp/proxy-manager-lts": "^1.0", "friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0", "geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5", "guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.116", "jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8", "laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13", "laminas/laminas-config-aggregator": "^1.13",
@@ -33,47 +33,50 @@
"laminas/laminas-inputfilter": "^2.27", "laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21", "laminas/laminas-servicemanager": "^3.21",
"laminas/laminas-stdlib": "^3.17", "laminas/laminas-stdlib": "^3.17",
"league/uri": "^6.8",
"matomo/matomo-php-tracker": "^3.2", "matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17", "mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.11", "mezzio/mezzio-fastroute": "^3.10",
"mezzio/mezzio-problem-details": "^1.13", "mezzio/mezzio-problem-details": "^1.13",
"mezzio/mezzio-swoole": "^4.7",
"mlocati/ip-lib": "^1.18", "mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8", "mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8", "pagerfanta/core": "^3.8",
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1", "shlinkio/shlink-common": "^5.7.1",
"shlinkio/shlink-common": "^6.0", "shlinkio/shlink-config": "^2.5",
"shlinkio/shlink-config": "^3.0", "shlinkio/shlink-event-dispatcher": "^3.1",
"shlinkio/shlink-event-dispatcher": "^4.0", "shlinkio/shlink-importer": "^5.2.1",
"shlinkio/shlink-importer": "^5.3", "shlinkio/shlink-installer": "^8.7",
"shlinkio/shlink-installer": "^9.0",
"shlinkio/shlink-ip-geolocation": "^4.0", "shlinkio/shlink-ip-geolocation": "^4.0",
"shlinkio/shlink-json": "^1.1", "shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.3", "spiral/roadrunner": "^2023.2",
"spiral/roadrunner-cli": "^2.6", "spiral/roadrunner-cli": "^2.5",
"spiral/roadrunner-http": "^3.3", "spiral/roadrunner-http": "^3.1",
"spiral/roadrunner-jobs": "^4.3", "spiral/roadrunner-jobs": "^4.0",
"symfony/console": "^7.0", "symfony/console": "^6.3",
"symfony/filesystem": "^7.0", "symfony/filesystem": "^6.3",
"symfony/lock": "^7.0", "symfony/lock": "^6.3",
"symfony/process": "^7.0", "symfony/process": "^6.3",
"symfony/string": "^7.0" "symfony/string": "^6.3"
}, },
"require-dev": { "require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1", "devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1", "devster/ubench": "^2.1",
"infection/infection": "^0.27",
"openswoole/ide-helper": "~22.0.0",
"phpstan/phpstan": "^1.10", "phpstan/phpstan": "^1.10",
"phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.3", "phpstan/phpstan-symfony": "^1.3",
"phpunit/php-code-coverage": "^10.1", "phpunit/php-code-coverage": "^10.1",
"phpunit/phpcov": "^9.0",
"phpunit/phpunit": "^10.4", "phpunit/phpunit": "^10.4",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0", "shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1", "shlinkio/shlink-test-utils": "^3.8.1",
"symfony/var-dumper": "^7.0", "symfony/var-dumper": "^6.3",
"veewee/composer-run-parallel": "^1.3" "veewee/composer-run-parallel": "^1.3"
}, },
"conflict": { "conflict": {
@@ -108,8 +111,8 @@
}, },
"scripts": { "scripts": {
"ci": [ "ci": [
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms", "@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 test:api:ci test:cli:ci" "@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db"
], ],
"cs": "phpcs -s", "cs": "phpcs -s",
"cs:fix": "phpcbf", "cs:fix": "phpcbf",
@@ -119,27 +122,54 @@
"@parallel test:api test:cli" "@parallel test:api test:cli"
], ],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox", "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov", "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: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": "@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", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh", "test:api": "bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov", "test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh",
"test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", "test:api:ci": "GENERATE_COVERAGE=yes 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", "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov", "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:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov", "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 --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5",
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5",
"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"
],
"infect:test:unit": [
"@test:unit:ci",
"@infect:ci:unit"
],
"infect:test:db": [
"@test:db:sqlite:ci",
"@infect:ci:db"
],
"infect:test:api": [
"@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:validate": "php-openapi validate docs/swagger/swagger.json",
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.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" "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
}, },
"scripts-descriptions": { "scripts-descriptions": {
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"</>", "ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>", "cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>", "cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>", "stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
@@ -160,6 +190,10 @@
"test:cli": "<fg=blue;options=bold>Runs CLI test suites</>", "test:cli": "<fg=blue;options=bold>Runs CLI test suites</>",
"test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>", "test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>",
"test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>", "test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>",
"infect:ci": "<fg=blue;options=bold>Checks unit and db tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:unit": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:db": "<fg=blue;options=bold>Checks db tests quality applying mutation testing with existing reports and logs</>",
"infect:test": "<fg=blue;options=bold>Runs unit and db tests, then checks tests quality applying mutation testing</>",
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>", "swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>", "swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>" "clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
@@ -170,6 +204,7 @@
"allow-plugins": { "allow-plugins": {
"composer/package-versions-deprecated": true, "composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true, "dealerdirect/phpcodesniffer-composer-installer": true,
"infection/extension-installer": true,
"veewee/composer-run-parallel": true "veewee/composer-run-parallel": true
} }
} }

View File

@@ -11,6 +11,7 @@ return (static function (): array {
'redis' => [ 'redis' => [
'servers' => $redisServers, 'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false),
], ],
]; ];

View File

@@ -8,7 +8,7 @@ return [
'debug' => false, 'debug' => false,
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // 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 // commands don't generate a cache file that's then used by php-fpm web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli', ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Application; use Mezzio\Application;
use Mezzio\Container; use Mezzio\Container;
use Psr\Http\Client\ClientInterface; use Psr\Http\Client\ClientInterface;
@@ -13,14 +12,12 @@ use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface;
use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface; use Spiral\RoadRunner\WorkerInterface;
use Symfony\Component\Filesystem\Filesystem;
return [ return [
'dependencies' => [ 'dependencies' => [
'factories' => [ 'factories' => [
PSR7Worker::class => ConfigAbstractFactory::class, PSR7Worker::class => ConfigAbstractFactory::class,
Filesystem::class => InvokableFactory::class,
], ],
'delegators' => [ 'delegators' => [

View File

@@ -16,10 +16,6 @@ return (static function (): array {
'mssql' => 'pdo_sqlsrv', 'mssql' => 'pdo_sqlsrv',
default => 'pdo_mysql', default => 'pdo_mysql',
}; };
$readCredentialAsString = static function (EnvVars $envVar): string|null {
$value = $envVar->loadFromEnv();
return $value === null ? null : (string) $value;
};
$resolveDefaultPort = static fn () => match ($driver) { $resolveDefaultPort = static fn () => match ($driver) {
'postgres' => '5432', 'postgres' => '5432',
'mssql' => '1433', 'mssql' => '1433',
@@ -32,7 +28,6 @@ return (static function (): array {
'postgres' => 'utf8', 'postgres' => 'utf8',
default => null, default => null,
}; };
$resolveConnection = static fn () => match ($driver) { $resolveConnection = static fn () => match ($driver) {
null, 'sqlite' => [ null, 'sqlite' => [
'driver' => 'pdo_sqlite', 'driver' => 'pdo_sqlite',
@@ -41,8 +36,8 @@ return (static function (): array {
default => [ default => [
'driver' => $resolveDriver(), 'driver' => $resolveDriver(),
'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'), 'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'),
'user' => $readCredentialAsString(EnvVars::DB_USER), 'user' => EnvVars::DB_USER->loadFromEnv(),
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD), 'password' => EnvVars::DB_PASSWORD->loadFromEnv(),
'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()), 'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()),
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()), 'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null, 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,

View File

@@ -21,6 +21,8 @@ return [
Option\Database\DatabaseUnixSocketConfigOption::class, Option\Database\DatabaseUnixSocketConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class, Option\UrlShortener\ShortDomainHostConfigOption::class,
Option\UrlShortener\ShortDomainSchemaConfigOption::class, Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class, Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class, Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class, Option\Redirect\Regular404RedirectConfigOption::class,
@@ -28,7 +30,10 @@ return [
Option\BasePathConfigOption::class, Option\BasePathConfigOption::class,
Option\TimezoneConfigOption::class, Option\TimezoneConfigOption::class,
Option\Cache\CacheNamespaceConfigOption::class, Option\Cache\CacheNamespaceConfigOption::class,
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class, Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisDecodeCredentialsConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class, Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class, Option\UrlShortener\ShortCodeLengthOption::class,
@@ -57,9 +62,6 @@ return [
Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class, Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\QrCode\DefaultColorConfigOption::class,
Option\QrCode\DefaultBgColorConfigOption::class,
Option\QrCode\DefaultLogoUrlConfigOption::class,
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class, Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class,

View File

@@ -7,21 +7,20 @@ namespace Shlinkio\Shlink;
use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\Factory\InvokableFactory;
use Monolog\Level; use Monolog\Level;
use Monolog\Logger; use Monolog\Logger;
use PhpMiddleware\RequestId;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Logger\LoggerFactory; use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use function Shlinkio\Shlink\Config\runningInRoadRunner; use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array { return (static function (): array {
$common = [ $common = [
'level' => Level::Info->value, 'level' => Level::Info->value,
'processors' => [RequestIdMiddleware::class], 'processors' => [RequestId\MonologProcessor::class],
'line_format' => 'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
]; ];
return [ return [
@@ -53,5 +52,16 @@ return (static function (): array {
], ],
], ],
// Deprecated. Remove in Shlink 4.0.0
'mezzio-swoole' => [
'swoole-http-server' => [
'logger' => [
// Let's disable mezio-swoole access logging, so that we can provide our own implementation,
// consistent for roadrunner and openswoole
'logger-name' => NullLogger::class,
],
],
],
]; ];
})(); })();

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
return [ return [
'mercure' => [ 'mercure' => [
'public_hub_url' => 'http://localhost:8002', 'public_hub_url' => 'http://localhost:8001',
'internal_hub_url' => 'http://shlink_mercure_proxy', 'internal_hub_url' => 'http://shlink_mercure_proxy',
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
], ],

View File

@@ -7,10 +7,10 @@ namespace Shlinkio\Shlink;
use Laminas\Stratigility\Middleware\ErrorHandler; use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\ProblemDetails; use Mezzio\ProblemDetails;
use Mezzio\Router; use Mezzio\Router;
use PhpMiddleware\RequestId\RequestIdMiddleware;
use RKA\Middleware\IpAddress; use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware; use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
return [ return [
@@ -47,6 +47,7 @@ return [
'rest' => [ 'rest' => [
'path' => '/rest', 'path' => '/rest',
'middleware' => [ 'middleware' => [
Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class,
Router\Middleware\ImplicitOptionsMiddleware::class, Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class,

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
@@ -28,9 +26,6 @@ return [
'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv( 'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(
DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
), ),
'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR),
'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR),
'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(),
], ],
]; ];

View File

@@ -14,6 +14,9 @@ return [
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
// Deprecated
'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false),
], ],
]; ];

View File

@@ -7,7 +7,6 @@ return [
'rabbitmq' => [ 'rabbitmq' => [
'enabled' => true, 'enabled' => true,
'host' => 'shlink_rabbitmq', 'host' => 'shlink_rabbitmq',
'port' => '5673',
'user' => 'rabbit', 'user' => 'rabbit',
'password' => 'rabbit', 'password' => 'rabbit',
], ],

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use PhpMiddleware\RequestId;
use Shlinkio\Shlink\Common\Logger\Processor\BackwardsCompatibleMonologProcessorDelegator;
return [
'request_id' => [
'allow_override' => true,
'header_name' => 'X-Request-Id',
],
'dependencies' => [
'factories' => [
RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class,
RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class,
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
],
'delegators' => [
RequestId\MonologProcessor::class => [
BackwardsCompatibleMonologProcessorDelegator::class,
],
],
],
ConfigAbstractFactory::class => [
RequestId\RequestIdProviderFactory::class => [
RequestId\Generator\RamseyUuid4StaticGenerator::class,
'config.request_id.allow_override',
'config.request_id.header_name',
],
RequestId\RequestIdMiddleware::class => [
RequestId\RequestIdProviderFactory::class,
'config.request_id.header_name',
],
RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class],
],
];

View File

@@ -11,7 +11,7 @@ return [
'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''),
'fastroute' => [ 'fastroute' => [
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // 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 // commands don't generate a cache file that's then used by php-fpm web executions
FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli', FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php', FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php',

View File

@@ -17,6 +17,7 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
use function sprintf; use function sprintf;
return (static function (): array { return (static function (): array {
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
@@ -31,10 +32,9 @@ return (static function (): array {
...ConfigProvider::applyRoutesPrefix([ ...ConfigProvider::applyRoutesPrefix([
Action\HealthAction::getRouteDef(), Action\HealthAction::getRouteDef(),
// Visits and rules routes must go first, as they have a more specific path, otherwise, when
// multi-segment slugs are enabled, routes with a less-specific path might match first
// Visits. // Visits.
// These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs
// are enabled, routes with a less-specific path might match first
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(), Action\Visit\TagVisitsAction::getRouteDef(),
@@ -44,18 +44,15 @@ return (static function (): array {
Action\Visit\DeleteOrphanVisitsAction::getRouteDef(), Action\Visit\DeleteOrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(), Action\Visit\NonOrphanVisitsAction::getRouteDef(),
//Redirect rules
Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
// Short URLs // Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([ Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware, $dropDomainMiddleware,
$overrideDomainMiddleware, $overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]), ]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class, $contentNegotiationMiddleware,
$overrideDomainMiddleware, $overrideDomainMiddleware,
]), ]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv;
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
return (static function (): array {
$taskWorkers = (int) EnvVars::TASK_WORKER_NUM->loadFromEnv(16);
return [
'mezzio-swoole' => [
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => '0.0.0.0',
'port' => (int) EnvVars::PORT->loadFromEnv(8080),
'process-name' => 'shlink',
'options' => [
...getOpenswooleConfigFromEnv(),
'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16),
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
],
],
],
];
})();

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'mezzio-swoole' => [
'hot-code-reload' => [
'enable' => true,
],
],
];

View File

@@ -14,7 +14,7 @@ return (static function (): array {
MIN_SHORT_CODES_LENGTH, MIN_SHORT_CODES_LENGTH,
); );
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; $mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT;
return [ return [
@@ -24,7 +24,7 @@ return (static function (): array {
'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''), 'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''),
], ],
'default_short_codes_length' => $shortCodesLength, 'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(true), 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false),
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),

View File

@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Config\runningInOpenswoole;
use function Shlinkio\Shlink\Config\runningInRoadRunner; use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [ return [
@@ -11,9 +12,11 @@ return [
'schema' => 'http', 'schema' => 'http',
'hostname' => sprintf('localhost:%s', match (true) { 'hostname' => sprintf('localhost:%s', match (true) {
runningInRoadRunner() => '8800', runningInRoadRunner() => '8800',
runningInOpenswoole() => '8080',
default => '8000', default => '8000',
}), }),
], ],
'auto_resolve_titles' => true,
// 'multi_segment_slugs_enabled' => true, // 'multi_segment_slugs_enabled' => true,
// 'trailing_slash_enabled' => true, // 'trailing_slash_enabled' => true,
], ],

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0
return (static function (): array {
$webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv();
return [
'visits_webhooks' => [
'webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' =>
(bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false),
],
];
})();

View File

@@ -8,12 +8,19 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros; use Laminas\Diactoros;
use Mezzio; use Mezzio;
use Mezzio\ProblemDetails; use Mezzio\ProblemDetails;
use Mezzio\Swoole;
use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider; use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
use function class_exists;
use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\enumValues;
use const PHP_SAPI;
$isTestEnv = env('APP_ENV') === 'test'; $isTestEnv = env('APP_ENV') === 'test';
$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
return (new ConfigAggregator\ConfigAggregator( return (new ConfigAggregator\ConfigAggregator(
providers: [ providers: [
@@ -23,6 +30,9 @@ return (new ConfigAggregator\ConfigAggregator(
Mezzio\ConfigProvider::class, Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class, Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class, ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class, Diactoros\ConfigProvider::class,
Common\ConfigProvider::class, Common\ConfigProvider::class,

View File

@@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5; const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4; const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
@@ -19,6 +19,6 @@ const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; // Deprecated. Shlink 4.0.0 should change default value to `true`
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = false;
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White const MIN_TASK_WORKERS = 4;

View File

@@ -12,6 +12,17 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php'; require 'vendor/autoload.php';
// Workaround to make this compatible with both openswoole 22 and earlier versions.
// Openswoole support is deprecated. Remove in v4.0.0
if (! function_exists('swoole_set_process_name')) {
// phpcs:disable
function swoole_set_process_name(string $name): void
{
OpenSwoole\Util::setProcessName($name);
}
// phpcs:enable
}
// This is one of the first files loaded. Configure the timezone here // This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get())); date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));

View File

@@ -1,49 +0,0 @@
version: '3'
############################################################################################
# Routes here need to be relative to the project root, as API tests are run with `-w .` #
# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 #
############################################################################################
rpc:
listen: tcp://127.0.0.1:6001
server:
command: 'php ./bin/roadrunner-worker.php'
http:
address: '0.0.0.0:9999'
middleware: ['static']
static:
dir: './public'
forbid: ['.php', '.htaccess']
pool:
num_workers: 1
debug: false
jobs:
pool:
num_workers: 1
debug: false
timeout: 300
consume: ['shlink']
pipelines:
shlink:
driver: memory
config:
priority: 10
prefetch: 10
logs:
encoding: json
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: json
level: info
metrics:
level: panic
jobs:
level: panic

View File

@@ -7,6 +7,12 @@ namespace Shlinkio\Shlink\TestUtils;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use function register_shutdown_function;
use function sprintf;
use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT;
/** @var ContainerInterface $container */ /** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php'; $container = require __DIR__ . '/../container.php';
$testHelper = $container->get(Helper\TestHelper::class); $testHelper = $container->get(Helper\TestHelper::class);
@@ -14,6 +20,14 @@ $config = $container->get('config');
$em = $container->get(EntityManager::class); $em = $container->get(EntityManager::class);
$httpClient = $container->get('shlink_test_api_client'); $httpClient = $container->get('shlink_test_api_client');
// Dump code coverage when process shuts down
register_shutdown_function(function () use ($httpClient): void {
$httpClient->request(
'GET',
sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT),
);
});
$testHelper->createTestDb( $testHelper->createTestDb(
createDbCommand: ['bin/cli', 'db:create'], createDbCommand: ['bin/cli', 'db:create'],
migrateDbCommand: ['bin/cli', 'db:migrate'], migrateDbCommand: ['bin/cli', 'db:migrate'],

View File

@@ -6,38 +6,75 @@ namespace Shlinkio\Shlink;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\FastRouteRouter; use League\Event\EventDispatcher;
use Monolog\Level; 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;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
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 Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware;
use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator;
use Shlinkio\Shlink\TestUtils\Helper\CoverageHelper;
use Symfony\Component\Console\Application; 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 Laminas\Stratigility\middleware; use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\env;
use function sleep; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function sprintf; use function sprintf;
use function sys_get_temp_dir;
use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT; use const ShlinkioTest\Shlink\API_TESTS_PORT;
$testEnv = env('TEST_ENV'); $isApiTest = env('TEST_ENV') === 'api';
$isApiTest = $testEnv === 'api'; $isCliTest = env('TEST_ENV') === 'cli';
$isCliTest = $testEnv === 'cli';
$isE2eTest = $isApiTest || $isCliTest; $isE2eTest = $isApiTest || $isCliTest;
$coverageType = env('GENERATE_COVERAGE'); $coverageType = env('GENERATE_COVERAGE');
$generateCoverage = $coverageType === 'yes'; $generateCoverage = contains($coverageType, ['yes', 'pretty']);
$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories(
[ $coverage = null;
__DIR__ . '/../../module/Core/src', if ($isE2eTest && $generateCoverage) {
__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src', $filter = new Filter();
], $filter->includeDirectory(__DIR__ . '/../../module/Core/src');
__DIR__ . '/../../build/coverage-' . $testEnv, $filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src');
) : null; $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 { $buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite'); $driver = env('DB_DRIVER', 'sqlite');
@@ -52,7 +89,7 @@ $buildDbConnection = static function (): array {
'postgres' => [ 'postgres' => [
'driver' => 'pdo_pgsql', 'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
'port' => $isCi ? '5434' : '5432', 'port' => $isCi ? '5433' : '5432',
'user' => 'postgres', 'user' => 'postgres',
'password' => 'root', 'password' => 'root',
'dbname' => 'shlink_test', 'dbname' => 'shlink_test',
@@ -91,7 +128,6 @@ return [
'debug' => true, 'debug' => true,
ConfigAggregator::ENABLE_CACHE => false, ConfigAggregator::ENABLE_CACHE => false,
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
'url_shortener' => [ 'url_shortener' => [
'domain' => [ 'domain' => [
@@ -100,27 +136,52 @@ return [
], ],
], ],
'routes' => [ 'mezzio-swoole' => [
// This route is used to test that title resolution is skipped if the long URL times out 'enable_coroutine' => false,
'swoole-http-server' => [
'host' => API_TESTS_HOST,
'port' => API_TESTS_PORT,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
'log_file' => __DIR__ . '/../../data/log/api-tests/output.log',
'enable_coroutine' => false,
],
],
],
'routes' => !$isApiTest ? [] : [
[ [
'name' => 'long_url_with_timeout', 'name' => 'dump_coverage',
'path' => '/api-tests/long-url-with-timeout', 'path' => '/api-tests/stop-coverage',
'allowed_methods' => ['GET'], 'middleware' => middleware(static function () use ($exportCoverage) {
'middleware' => middleware(static function () { // TODO I have tried moving this block to a listener so that it's invoked automatically,
sleep(5); // Title resolution times out at 3 seconds // but then the coverage is generated empty ¯\_(ツ)_/¯
return new HtmlResponse('<title>The title</title>'); $exportCoverage();
return new EmptyResponse();
}), }),
'allowed_methods' => ['GET'],
], ],
], ],
'middleware_pipeline' => !$isApiTest ? [] : [ 'middleware_pipeline' => !$isApiTest ? [] : [
'capture_code_coverage' => [ 'capture_code_coverage' => [
'middleware' => new CoverageMiddleware($coverage), 'middleware' => middleware(static function (
ServerRequestInterface $req,
RequestHandlerInterface $handler,
) use (&$coverage): ResponseInterface {
$coverage?->start($req->getHeaderLine('x-coverage-id'));
try {
return $handler->handle($req);
} finally {
$coverage?->stop();
}
}),
'priority' => 9999, 'priority' => 9999,
], ],
], ],
// Disable mercure integration during E2E tests
'mercure' => [ 'mercure' => [
'public_hub_url' => null, 'public_hub_url' => null,
'internal_hub_url' => null, 'internal_hub_url' => null,
@@ -139,7 +200,58 @@ return [
], ],
'delegators' => $isCliTest ? [ 'delegators' => $isCliTest ? [
Application::class => [ Application::class => [
new CliCoverageDelegator($coverage), 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;
},
], ],
] : [], ] : [],
], ],
@@ -150,7 +262,7 @@ return [
'data_fixtures' => [ 'data_fixtures' => [
'paths' => [ 'paths' => [
// TODO These are used for other module's tests, so maybe should be somewhere else // TODO These are used for CLI tests too, so maybe should be somewhere else
__DIR__ . '/../../module/Rest/test-api/Fixtures', __DIR__ . '/../../module/Rest/test-api/Fixtures',
], ],
], ],

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ { location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php; fastcgi_index index.php;
include fastcgi.conf; include fastcgi.conf;
} }

View File

@@ -0,0 +1,13 @@
/var/log/shlink/shlink_openswoole.log {
su root root
daily
missingok
rotate 120
compress
delaycompress
notifempty
create 0640 root root
postrotate
/etc/init.d/shlink_openswoole restart
endscript
}

View File

@@ -0,0 +1,54 @@
#!/bin/bash
### BEGIN INIT INFO
# Provides: shlink_openswoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: Shlink non-blocking server with openswoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start
RUNAS=root
PIDFILE=/var/run/shlink_openswoole.pid
LOGDIR=/var/log/shlink
LOGFILE=${LOGDIR}/shlink_openswoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with openswoole already running' >&2
return 1
fi
echo 'Starting shlink with openswoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
su -c "$CMD" $RUNAS > "$PIDFILE"
echo 'Shlink started' >&2
}
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with openswoole not running' >&2
return 1
fi
echo 'Stopping shlink with openswoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac

View File

@@ -1,8 +1,8 @@
FROM php:8.3-fpm-alpine3.19 FROM php:8.2-fpm-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com> MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23 ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.12.0 ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

View File

@@ -1,8 +1,8 @@
FROM php:8.3-alpine3.19 FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com> MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23 ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.12.0 ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

View File

@@ -0,0 +1,85 @@
FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
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 apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
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 inotify extension
ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
RUN mkdir -p /usr/src/php/ext/inotify \
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \
&& docker-php-ext-configure inotify \
&& docker-php-ext-install inotify \
&& rm /tmp/inotify.tar.gz
# Install openswoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable openswoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql${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 openswoole port
EXPOSE 8080
CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; 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

View File

@@ -0,0 +1,14 @@
server {
listen 80 default_server;
error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log;
location / {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://shlink_swoole:8080;
proxy_read_timeout 90s;
}
}

View File

@@ -7,6 +7,12 @@ services:
- /etc/passwd:/etc/passwd:ro - /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro - /etc/group:/etc/group:ro
shlink_swoole:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_roadrunner: shlink_roadrunner:
user: 1000:1000 user: 1000:1000
volumes: volumes:

View File

@@ -39,6 +39,44 @@ services:
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
image: nginx:1.25-alpine
ports:
- "8002:80"
volumes:
- ./:/home/shlink/www
- ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
links:
- shlink_swoole
shlink_swoole:
container_name: shlink_swoole
build:
context: .
dockerfile: ./data/infra/swoole.Dockerfile
ports:
- "8080:8080"
- "9001:9001"
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_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_roadrunner: shlink_roadrunner:
container_name: shlink_roadrunner container_name: shlink_roadrunner
build: build:
@@ -81,7 +119,7 @@ services:
container_name: shlink_db_postgres container_name: shlink_db_postgres
image: postgres:12.2-alpine image: postgres:12.2-alpine
ports: ports:
- "5434:5432" - "5433:5432"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
- ./data/infra/database_pg:/var/lib/postgresql/data - ./data/infra/database_pg:/var/lib/postgresql/data
@@ -131,7 +169,7 @@ services:
container_name: shlink_mercure_proxy container_name: shlink_mercure_proxy
image: nginx:1.25-alpine image: nginx:1.25-alpine
ports: ports:
- "8002:80" - "8001:80"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
@@ -153,15 +191,15 @@ services:
container_name: shlink_rabbitmq container_name: shlink_rabbitmq
image: rabbitmq:3.11-management-alpine image: rabbitmq:3.11-management-alpine
ports: ports:
- "15673:15672" - "15672:15672"
- "5673:5672" - "5672:5672"
environment: environment:
RABBITMQ_DEFAULT_USER: "rabbit" RABBITMQ_DEFAULT_USER: "rabbit"
RABBITMQ_DEFAULT_PASS: "rabbit" RABBITMQ_DEFAULT_PASS: "rabbit"
shlink_swagger_ui: shlink_swagger_ui:
container_name: shlink_swagger_ui container_name: shlink_swagger_ui
image: swaggerapi/swagger-ui:v5.11.3 image: swaggerapi/swagger-ui:v5.10.3
ports: ports:
- "8005:8080" - "8005:8080"
volumes: volumes:

View File

@@ -5,7 +5,7 @@
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime. This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev), which can be linked to external databases to persist data. It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data.
## Usage ## Usage

View File

@@ -20,6 +20,19 @@ fi
php vendor/bin/shlink-installer init ${flags} php vendor/bin/shlink-installer init ${flags}
if [ "$SHLINK_RUNTIME" = 'rr' ]; then # Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then
# Openswoole is deprecated. Remove in Shlink 4.0.0
# 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 ./bin/rr serve -c config/roadrunner/.rr.yml
fi fi

View File

@@ -111,6 +111,9 @@
"type": "string", "type": "string",
"description": "The original long URL." "description": "The original long URL."
}, },
"deviceLongUrls": {
"$ref": "#/components/schemas/DeviceLongUrls"
},
"dateCreated": { "dateCreated": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
@@ -119,6 +122,11 @@
"visitsSummary": { "visitsSummary": {
"$ref": "#/components/schemas/VisitsSummary" "$ref": "#/components/schemas/VisitsSummary"
}, },
"visitsCount": {
"deprecated": true,
"type": "integer",
"description": "The number of visits that this short URL has received."
},
"tags": { "tags": {
"type": "array", "type": "array",
"items": { "items": {
@@ -147,6 +155,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": "https://store.steampowered.com/android",
"ios": "https://store.steampowered.com/ios",
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 328, "total": 328,
@@ -210,6 +223,24 @@
} }
} }
}, },
"DeviceLongUrls": {
"type": "object",
"required": ["android", "ios", "desktop"],
"properties": {
"android": {
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
"type": "string"
},
"ios": {
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
"type": "string"
},
"desktop": {
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
"type": "string"
}
}
},
"Visit": { "Visit": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -0,0 +1,17 @@
{
"type": "object",
"properties": {
"android": {
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
"type": ["string"]
},
"ios": {
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
"type": ["string"]
},
"desktop": {
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
"type": ["string"]
}
}
}

View File

@@ -0,0 +1,17 @@
{
"type": "object",
"allOf": [{
"$ref": "./DeviceLongUrls.json"
}],
"properties": {
"android": {
"type": ["null"]
},
"ios": {
"type": ["null"]
},
"desktop": {
"type": ["null"]
}
}
}

View File

@@ -0,0 +1,7 @@
{
"type": "object",
"required": ["android", "ios", "desktop"],
"allOf": [{
"$ref": "./DeviceLongUrlsEdit.json"
}]
}

View File

@@ -1,31 +0,0 @@
{
"type": "object",
"required": ["longUrl", "conditions"],
"properties": {
"longUrl": {
"description": "Long URL to redirect to when this condition matches",
"type": "string"
},
"conditions": {
"description": "List of conditions that need to match in order to consider this rule matches",
"type": "array",
"items": {
"type": "object",
"required": ["type", "matchKey", "matchValue"],
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param"],
"description": "The type of the condition, which will condition the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]
},
"matchValue": {
"type": "string"
}
}
}
}
}
}

View File

@@ -4,7 +4,9 @@
"shortCode", "shortCode",
"shortUrl", "shortUrl",
"longUrl", "longUrl",
"deviceLongUrls",
"dateCreated", "dateCreated",
"visitsCount",
"visitsSummary", "visitsSummary",
"tags", "tags",
"meta", "meta",
@@ -26,11 +28,19 @@
"type": "string", "type": "string",
"description": "The original long URL." "description": "The original long URL."
}, },
"deviceLongUrls": {
"$ref": "./DeviceLongUrlsResp.json"
},
"dateCreated": { "dateCreated": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"description": "The date in which the short URL was created in ISO format." "description": "The date in which the short URL was created in ISO format."
}, },
"visitsCount": {
"deprecated": true,
"type": "integer",
"description": "**[DEPRECATED]** Use `visitsSummary.total` instead."
},
"visitsSummary": { "visitsSummary": {
"$ref": "./VisitsSummary.json" "$ref": "./VisitsSummary.json"
}, },

View File

@@ -5,6 +5,9 @@
"description": "The long URL this short URL will redirect to", "description": "The long URL this short URL will redirect to",
"type": "string" "type": "string"
}, },
"deviceLongUrls": {
"$ref": "./DeviceLongUrlsEdit.json"
},
"validSince": { "validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid", "description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": ["string", "null"] "type": ["string", "null"]
@@ -17,6 +20,11 @@
"description": "The maximum number of allowed visits for this short code", "description": "The maximum number of allowed visits for this short code",
"type": ["number", "null"] "type": ["number", "null"]
}, },
"validateUrl": {
"deprecated": true,
"description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`",
"type": "boolean"
},
"tags": { "tags": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -1,13 +0,0 @@
{
"type": "object",
"required": ["priority"],
"properties": {
"priority": {
"description": "Order in which attempting to match the rule. Lower goes first",
"type": "number"
}
},
"allOf": [{
"$ref": "./SetShortUrlRedirectRule.json"
}]
}

View File

@@ -1,6 +1,6 @@
{ {
"type": "object", "type": "object",
"required": ["tag", "shortUrlsCount", "visitsSummary"], "required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"],
"properties": { "properties": {
"tag": { "tag": {
"type": "string", "type": "string",
@@ -12,6 +12,11 @@
}, },
"visitsSummary": { "visitsSummary": {
"$ref": "./VisitsSummary.json" "$ref": "./VisitsSummary.json"
},
"visitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use visitsSummary.total instead"
} }
} }
} }

View File

@@ -1,12 +1,22 @@
{ {
"type": "object", "type": "object",
"required": ["nonOrphanVisits", "orphanVisits"], "required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"],
"properties": { "properties": {
"nonOrphanVisits": { "nonOrphanVisits": {
"$ref": "./VisitsSummary.json" "$ref": "./VisitsSummary.json"
}, },
"orphanVisits": { "orphanVisits": {
"$ref": "./VisitsSummary.json" "$ref": "./VisitsSummary.json"
},
"visitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use nonOrphanVisits.total instead"
},
"orphanVisitsCount": {
"deprecated": true,
"type": "number",
"description": "**[DEPRECATED]** Use orphanVisits.total instead"
} }
} }
} }

View File

@@ -0,0 +1,9 @@
{
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["maxVisits", "validSince"]
}
}

View File

@@ -0,0 +1,9 @@
{
"value": {
"detail": "No URL found with short code \"abc123\"",
"title": "Short URL not found",
"type": "INVALID_SHORTCODE",
"status": 404,
"shortCode": "abc123"
}
}

View File

@@ -0,0 +1,9 @@
{
"value": {
"detail": "Tag with name \"foo\" could not be found",
"title": "Tag not found",
"type": "TAG_NOT_FOUND",
"status": 404,
"tag": "foo"
}
}

View File

@@ -163,6 +163,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 328, "total": 328,
@@ -186,6 +191,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": null,
"ios": "https://shlink.io/ios",
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,
@@ -208,6 +218,11 @@
"shortCode": "123bA", "shortCode": "123bA",
"shortUrl": "https://example.com/123bA", "shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com", "longUrl": "https://www.google.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2015-10-01T20:34:16+02:00", "dateCreated": "2015-10-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 25, "total": 25,
@@ -281,14 +296,13 @@
"type": "object", "type": "object",
"required": ["longUrl"], "required": ["longUrl"],
"properties": { "properties": {
"deviceLongUrls": {
"$ref": "../definitions/DeviceLongUrls.json"
},
"customSlug": { "customSlug": {
"description": "A unique custom slug to be used instead of the generated short code", "description": "A unique custom slug to be used instead of the generated short code",
"type": "string" "type": "string"
}, },
"pathPrefix": {
"description": "A prefix that will be prepended to provided custom slug or auto-generated short code",
"type": "string"
},
"findIfExists": { "findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one", "description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean" "type": "boolean"
@@ -320,6 +334,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 0, "total": 0,
@@ -363,13 +382,16 @@
"validSince", "validSince",
"validUntil", "validUntil",
"customSlug", "customSlug",
"pathPrefix",
"maxVisits", "maxVisits",
"findIfExists", "findIfExists",
"domain" "domain"
] ]
} }
}, },
"url": {
"type": "string",
"description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url"
},
"customSlug": { "customSlug": {
"type": "string", "type": "string",
"description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug" "description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug"
@@ -383,10 +405,19 @@
] ]
}, },
"examples": { "examples": {
"Invalid arguments": { "Invalid arguments with API v3 and newer": {
"$ref": "../examples/short-url-invalid-args-v3.json" "$ref": "../examples/short-url-invalid-args-v3.json"
}, },
"Non-unique slug": { "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": { "value": {
"title": "Invalid custom slug", "title": "Invalid custom slug",
"type": "https://shlink.io/api/error/non-unique-slug", "type": "https://shlink.io/api/error/non-unique-slug",
@@ -394,6 +425,27 @@
"status": 400, "status": 400,
"customSlug": "my-slug" "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",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Non-unique slug previous to API v3": {
"value": {
"title": "Invalid custom slug",
"type": "INVALID_SLUG",
"detail": "Provided slug \"my-slug\" is already in use.",
"status": 400,
"customSlug": "my-slug"
}
} }
} }
} }

View File

@@ -53,6 +53,11 @@
}, },
"example": { "example": {
"longUrl": "https://github.com/shlinkio/shlink", "longUrl": "https://github.com/shlinkio/shlink",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"shortUrl": "https://s.test/abc123", "shortUrl": "https://s.test/abc123",
"shortCode": "abc123", "shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
@@ -83,6 +88,49 @@
} }
} }
}, },
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"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"
},
"examples": {
"API v3 and newer": {
"value": "https://shlink.io/api/error/invalid-url"
},
"Previous to API v3": {
"value": "INVALID_URL"
}
}
}
}
},
"default": { "default": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {

View File

@@ -34,6 +34,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,
@@ -81,8 +86,11 @@
] ]
}, },
"examples": { "examples": {
"Short URL not found": { "API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json" "$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
} }
} }
} }
@@ -147,6 +155,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": "https://shlink.io/android",
"ios": null,
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,
@@ -199,8 +212,11 @@
] ]
}, },
"examples": { "examples": {
"Invalid arguments": { "API v3 and newer": {
"$ref": "../examples/short-url-invalid-args-v3.json" "$ref": "../examples/short-url-invalid-args-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
} }
} }
} }
@@ -232,8 +248,11 @@
] ]
}, },
"examples": { "examples": {
"Short URL not found": { "API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json" "$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
} }
} }
} }
@@ -359,8 +378,11 @@
] ]
}, },
"examples": { "examples": {
"Short URL not found": { "API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json" "$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
} }
} }
} }

View File

@@ -145,8 +145,11 @@
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
}, },
"examples": { "examples": {
"Short URL not found": { "Short URL not found with API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json" "$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
} }
} }
} }
@@ -216,8 +219,11 @@
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
}, },
"examples": { "examples": {
"Short URL not found": { "Short URL not found with API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json" "$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
} }
} }
} }

View File

@@ -15,6 +15,20 @@
{ {
"$ref": "../parameters/version.json" "$ref": "../parameters/version.json"
}, },
{
"name": "withStats",
"deprecated": true,
"description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
},
{ {
"name": "page", "name": "page",
"in": "query", "in": "query",
@@ -74,6 +88,13 @@
"type": "string" "type": "string"
} }
}, },
"stats": {
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
"type": "array",
"items": {
"$ref": "../definitions/TagInfo.json"
}
},
"pagination": { "pagination": {
"$ref": "../definitions/Pagination.json" "$ref": "../definitions/Pagination.json"
} }
@@ -228,6 +249,9 @@
"examples": { "examples": {
"API v3 and newer": { "API v3 and newer": {
"$ref": "../examples/tag-not-found-v3.json" "$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
} }
} }
} }

View File

@@ -148,8 +148,12 @@
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
}, },
"examples": { "examples": {
"Tag not found": {
"API v3 and newer": {
"$ref": "../examples/tag-not-found-v3.json" "$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
} }
} }
} }

View File

@@ -55,16 +55,6 @@
"type": "string", "type": "string",
"enum": ["true"] "enum": ["true"]
} }
},
{
"name": "type",
"in": "query",
"description": "The type of visits to return. All visits are returned when not provided.",
"required": false,
"schema": {
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
} }
], ],
"security": [ "security": [
@@ -147,54 +137,6 @@
} }
} }
}, },
"400": {
"description": "Provided query arguments are invalid.",
"content": {
"application/problem+json": {
"schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": ["type"]
}
}
}
}
]
},
"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": ["type"]
}
},
"Previous to API v3": {
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["type"]
}
}
}
}
}
},
"default": { "default": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {

View File

@@ -1,344 +0,0 @@
{
"get": {
"operationId": "listShortUrlRedirectRules",
"tags": [
"Redirect rules"
],
"summary": "List short URL redirect rules",
"description": "Returns the list of redirect rules for a short URL.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "The list of rules",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["defaultLongUrl", "redirectRules"],
"properties": {
"defaultLongUrl": {
"type": "string"
},
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrlRedirectRule.json"
}
}
}
},
"example": {
"defaultLongUrl": "https://example.com",
"redirectRules": [
{
"longUrl": "https://example.com/android-en-us",
"priority": 1,
"conditions": [
{
"type": "device",
"matchValue": "android",
"matchKey": null
},
{
"type": "language",
"matchValue": "en-US",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/fr",
"priority": 2,
"conditions": [
{
"type": "language",
"matchValue": "fr",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/query-foo-bar-hello-world",
"priority": 3,
"conditions": [
{
"type": "query",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"matchKey": "hello",
"matchValue": "world"
}
]
}
]
}
}
}
},
"404": {
"description": "No URL was found for provided short code.",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
}
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
},
"post": {
"operationId": "setShortUrlRedirectRules",
"tags": [
"Redirect rules"
],
"summary": "Set short URL redirect rules",
"description": "Sets redirect rules for a short URL, with priorities matching the order in which they are provided.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [
{
"ApiKey": []
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/SetShortUrlRedirectRule.json"
}
}
}
},
"example": {
"redirectRules": [
{
"longUrl": "https://example.com/android-en-us",
"conditions": [
{
"type": "device",
"matchValue": "android",
"matchKey": null
},
{
"type": "language",
"matchValue": "en-US",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/fr",
"conditions": [
{
"type": "language",
"matchValue": "fr",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/query-foo-bar-hello-world",
"conditions": [
{
"type": "query",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"matchKey": "hello",
"matchValue": "world"
}
]
}
]
}
}
}
},
"responses": {
"200": {
"description": "The list of rules",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["defaultLongUrl", "redirectRules"],
"properties": {
"defaultLongUrl": {
"type": "string"
},
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrlRedirectRule.json"
}
}
}
},
"example": {
"defaultLongUrl": "https://example.com",
"redirectRules": [
{
"longUrl": "https://example.com/android-en-us",
"priority": 1,
"conditions": [
{
"type": "device",
"matchValue": "android",
"matchKey": null
},
{
"type": "language",
"matchValue": "en-US",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/fr",
"priority": 2,
"conditions": [
{
"type": "language",
"matchValue": "fr",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/query-foo-bar-hello-world",
"priority": 3,
"conditions": [
{
"type": "query",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"matchKey": "hello",
"matchValue": "world"
}
]
}
]
}
}
}
},
"404": {
"description": "No URL was found for provided short code.",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
}
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -65,26 +65,6 @@
"enum": ["true", "false"], "enum": ["true", "false"],
"default": "false" "default": "false"
} }
},
{
"name": "color",
"in": "query",
"description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#000000"
}
},
{
"name": "bgColor",
"in": "query",
"description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#ffffff"
}
} }
], ],
"responses": { "responses": {

View File

@@ -42,10 +42,6 @@
"name": "Short URLs", "name": "Short URLs",
"description": "Operations that can be performed on short URLs" "description": "Operations that can be performed on short URLs"
}, },
{
"name": "Redirect rules",
"description": "Handle dynamic rule-based redirects"
},
{ {
"name": "Tags", "name": "Tags",
"description": "Let you handle the list of available tags" "description": "Let you handle the list of available tags"
@@ -83,10 +79,6 @@
"$ref": "paths/v1_short-urls_{shortCode}.json" "$ref": "paths/v1_short-urls_{shortCode}.json"
}, },
"/rest/v{version}/short-urls/{shortCode}/redirect-rules": {
"$ref": "paths/v3_short-urls_{shortCode}_redirect-rules.json"
},
"/rest/v{version}/tags": { "/rest/v{version}/tags": {
"$ref": "paths/v1_tags.json" "$ref": "paths/v1_tags.json"
}, },

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run docker containers if they are not up yet # Run docker containers if they are not up yet
if ! [[ $(docker ps | grep shlink_roadrunner) ]]; then if ! [[ $(docker ps | grep shlink_swoole) ]]; then
docker compose up -d docker compose up -d
fi fi
docker exec -it shlink_roadrunner /bin/sh -c "$*" docker exec -it shlink_swoole /bin/sh -c "$*"

24
infection-api.json5 Normal file
View File

@@ -0,0 +1,24 @@
{
source: {
directories: [
'module/*/src'
]
},
timeout: 5,
logs: {
text: 'build/infection-api/infection-log.txt',
html: 'build/infection-api/infection-log.html',
summary: 'build/infection-api/summary-log.txt',
debug: 'build/infection-api/debug-log.txt'
},
tmpDir: 'build/infection-api/temp',
phpUnit: {
configDir: '.'
},
testFrameworkOptions: '--configuration=phpunit-api.xml',
mutators: {
'@default': true,
IdenticalEqual: false,
NotIdenticalNotEqual: false
}
}

24
infection-cli.json5 Normal file
View File

@@ -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
}
}

24
infection-db.json5 Normal file
View File

@@ -0,0 +1,24 @@
{
source: {
directories: [
'module/*/src'
]
},
timeout: 5,
logs: {
text: 'build/infection-db/infection-log.txt',
html: 'build/infection-db/infection-log.html',
summary: 'build/infection-db/summary-log.txt',
debug: 'build/infection-db/debug-log.txt'
},
tmpDir: 'build/infection-db/temp',
phpUnit: {
configDir: '.'
},
testFrameworkOptions: '--configuration=phpunit-db.xml',
mutators: {
'@default': true,
IdenticalEqual: false,
NotIdenticalNotEqual: false
}
}

26
infection.json5 Normal file
View File

@@ -0,0 +1,26 @@
{
source: {
directories: [
'module/*/src'
]
},
timeout: 5,
logs: {
text: 'build/infection-unit/infection-log.txt',
html: 'build/infection-unit/infection-log.html',
summary: 'build/infection-unit/summary-log.txt',
debug: 'build/infection-unit/debug-log.txt',
stryker: {
report: 'develop'
}
},
tmpDir: 'build/infection-unit/temp',
phpUnit: {
configDir: '.'
},
mutators: {
'@default': true,
IdenticalEqual: false,
NotIdenticalNotEqual: false
}
}

View File

@@ -37,9 +37,6 @@ return [
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
Command\RedirectRule\ManageRedirectRulesCommand::class,
], ],
], ],

View File

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Tag\TagService;
@@ -34,7 +33,6 @@ return [
PhpExecutableFinder::class => InvokableFactory::class, PhpExecutableFinder::class => InvokableFactory::class,
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class, GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class, Util\ProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class, ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
@@ -68,8 +66,6 @@ return [
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
], ],
], ],
@@ -121,12 +117,6 @@ return [
Command\Domain\DomainRedirectsCommand::class => [DomainService::class], Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,
ShortUrlRedirectRuleService::class,
RedirectRule\RedirectRuleHandler::class,
],
Command\Db\CreateDatabaseCommand::class => [ Command\Db\CreateDatabaseCommand::class => [
LockFactory::class, LockFactory::class,
Util\ProcessRunner::class, Util\ProcessRunner::class,

View File

@@ -31,7 +31,7 @@ class DisableKeyCommand extends Command
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable'); ->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$apiKey = $input->getArgument('apiKey'); $apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);

View File

@@ -98,7 +98,7 @@ class GenerateKeyCommand extends Command
->setHelp($help); ->setHelp($help);
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$expirationDate = $input->getOption('expiration-date'); $expirationDate = $input->getOption('expiration-date');

View File

@@ -29,7 +29,7 @@ class InitialApiKeyCommand extends Command
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create'); ->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$key = $input->getArgument('apiKey'); $key = $input->getArgument('apiKey');
$result = $this->apiKeyService->createInitial($key); $result = $this->apiKeyService->createInitial($key);

View File

@@ -45,7 +45,7 @@ class ListKeysCommand extends Command
); );
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$enabledOnly = $input->getOption('enabled-only'); $enabledOnly = $input->getOption('enabled-only');

View File

@@ -68,7 +68,7 @@ class DomainRedirectsCommand extends Command
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption); $input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$domainAuthority = $input->getArgument('domain'); $domainAuthority = $input->getArgument('domain');

View File

@@ -38,7 +38,7 @@ class ListDomainsCommand extends Command
); );
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$domains = $this->domainService->listDomains(); $domains = $this->domainService->listDomains();
$showRedirects = $input->getOption('show-redirects'); $showRedirects = $input->getOption('show-redirects');

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class ManageRedirectRulesCommand extends Command
{
public const NAME = 'short-url:manage-rules';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(
protected readonly ShortUrlResolverInterface $shortUrlResolver,
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
protected readonly RedirectRuleHandlerInterface $ruleHandler,
) {
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code which rules we want to set.',
domainDesc: 'The domain for the short code.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Set redirect rules for a short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
} catch (ShortUrlNotFoundException) {
$io->error(sprintf('Short URL for %s not found', $identifier->__toString()));
return ExitCode::EXIT_FAILURE;
}
$rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl));
if ($rulesToSave !== null) {
$this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave);
$io->success('Rules properly saved');
}
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@@ -70,12 +71,6 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED, InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code', 'If provided, this slug will be used instead of generating a short code',
) )
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption( ->addOption(
'max-visits', 'max-visits',
'm', 'm',
@@ -100,6 +95,12 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED, InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --custom-slug was provided).', 'The length for generated short code (it will be ignored if --custom-slug was provided).',
) )
->addOption(
'validate-url',
null,
InputOption::VALUE_NONE,
'[DEPRECATED] Makes the URL to be validated as publicly accessible.',
)
->addOption( ->addOption(
'crawlable', 'crawlable',
'r', 'r',
@@ -133,7 +134,7 @@ class CreateShortUrlCommand extends Command
} }
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$io = $this->getIO($input, $output); $io = $this->getIO($input, $output);
$longUrl = $input->getArgument('longUrl'); $longUrl = $input->getArgument('longUrl');
@@ -144,20 +145,22 @@ class CreateShortUrlCommand extends Command
$explodeWithComma = static fn (string $tag) => explode(',', $tag); $explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits'); $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 { try {
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl, ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
@@ -173,7 +176,7 @@ class CreateShortUrlCommand extends Command
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)), sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]); ]);
return ExitCode::EXIT_SUCCESS; return ExitCode::EXIT_SUCCESS;
} catch (NonUniqueSlugException $e) { } catch (InvalidUrlException | NonUniqueSlugException $e) {
$io->error($e->getMessage()); $io->error($e->getMessage());
return ExitCode::EXIT_FAILURE; return ExitCode::EXIT_FAILURE;
} }

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -21,16 +21,9 @@ class DeleteShortUrlCommand extends Command
{ {
public const NAME = 'short-url:delete'; public const NAME = 'short-url:delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService) public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{ {
parent::__construct(); parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL to be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
);
} }
protected function configure(): void protected function configure(): void
@@ -38,19 +31,26 @@ class DeleteShortUrlCommand extends Command
$this $this
->setName(self::NAME) ->setName(self::NAME)
->setDescription('Deletes a short URL') ->setDescription('Deletes a short URL')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code for the short URL to be deleted')
->addOption( ->addOption(
'ignore-threshold', 'ignore-threshold',
'i', 'i',
InputOption::VALUE_NONE, InputOption::VALUE_NONE,
'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' 'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted', . 'accidentally deleted',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain if the short code does not belong to the default one',
); );
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); $identifier = ShortUrlIdentifier::fromCli($input);
$ignoreThreshold = $input->getOption('ignore-threshold'); $ignoreThreshold = $input->getOption('ignore-threshold');
try { try {

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand; use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf; use function sprintf;
@@ -18,28 +20,32 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
{ {
public const NAME = 'short-url:visits-delete'; public const NAME = 'short-url:visits-delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter) public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
{ {
parent::__construct(); parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL which visits will be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
);
} }
protected function configure(): void protected function configure(): void
{ {
$this $this
->setName(self::NAME) ->setName(self::NAME)
->setDescription('Deletes visits from a short URL'); ->setDescription('Deletes visits from a short URL')
->addArgument(
'shortCode',
InputArgument::REQUIRED,
'The short code for the short URL which visits will be deleted',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain if the short code does not belong to the default one',
);
} }
protected function doExecute(InputInterface $input, SymfonyStyle $io): int protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int
{ {
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); $identifier = ShortUrlIdentifier::fromCli($input);
try { try {
$result = $this->deleter->deleteShortUrlVisits($identifier); $result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
@@ -18,23 +20,18 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
{ {
public const NAME = 'short-url:visits'; public const NAME = 'short-url:visits';
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
protected function configure(): void protected function configure(): void
{ {
$this $this
->setName(self::NAME) ->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code'); ->setDescription('Returns the detailed visits information for provided short code')
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
$this, ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
shortCodeDesc: 'The short code which visits we want to get.',
domainDesc: 'The domain for the short code.',
);
} }
protected function interact(InputInterface $input, OutputInterface $output): void protected function interact(InputInterface $input, OutputInterface $output): void
{ {
$shortCode = $this->shortUrlIdentifierInput->shortCode($input); $shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) { if (! empty($shortCode)) {
return; return;
} }
@@ -48,7 +45,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{ {
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); $identifier = ShortUrlIdentifier::fromCli($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
} }

View File

@@ -129,7 +129,7 @@ class ListShortUrlsCommand extends Command
); );
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
@@ -218,7 +218,7 @@ class ListShortUrlsCommand extends Command
'Short URL' => $pickProp('shortUrl'), 'Short URL' => $pickProp('shortUrl'),
'Long URL' => $pickProp('longUrl'), 'Long URL' => $pickProp('longUrl'),
'Date created' => $pickProp('dateCreated'), 'Date created' => $pickProp('dateCreated'),
'Visits count' => static fn (array $shortUrl) => $shortUrl['visitsSummary']->total, 'Visits count' => $pickProp('visitsCount'),
]; ];
if ($input->getOption('show-tags')) { if ($input->getOption('show-tags')) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']); $columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);

Some files were not shown because too many files have changed in this diff Show More