From 122ad1e93c1b19e32317dc08a74e1333504ffcf7 Mon Sep 17 00:00:00 2001 From: codaholic Date: Thu, 10 Jul 2025 15:30:00 +0100 Subject: [PATCH] feat: schema-context-bundle --- .github/workflows/ci.yaml | 79 ++++++++++++++++ .github/workflows/security.yaml | 24 +++++ .github/workflows/static-analysis.yaml | 55 +++++++++++ .gitignore | 9 ++ README.md | 91 ++++++++++++++++++- SECURITY.md | 23 +++++ composer.json | 49 ++++++++++ config/services.yaml | 23 +++++ phpcs.xml.dist | 41 +++++++++ phpstan.neon | 10 ++ phpunit.xml.dist | 23 +++++ src/DependencyInjection/Configuration.php | 29 ++++++ .../SchemaContextExtension.php | 28 ++++++ src/EventListener/SchemaRequestListener.php | 46 ++++++++++ src/Messenger/Middleware/SchemaMiddleware.php | 37 ++++++++ src/Messenger/Stamp/SchemaStamp.php | 14 +++ src/SchemaContextBundle.php | 11 +++ src/Service/SchemaResolver.php | 20 ++++ .../SchemaRequestListenerTest.php | 55 +++++++++++ .../Middleware/SchemaMiddlewareTest.php | 67 ++++++++++++++ 20 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/security.yaml create mode 100644 .github/workflows/static-analysis.yaml create mode 100644 .gitignore create mode 100644 SECURITY.md create mode 100644 composer.json create mode 100644 config/services.yaml create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/SchemaContextExtension.php create mode 100644 src/EventListener/SchemaRequestListener.php create mode 100644 src/Messenger/Middleware/SchemaMiddleware.php create mode 100644 src/Messenger/Stamp/SchemaStamp.php create mode 100644 src/SchemaContextBundle.php create mode 100644 src/Service/SchemaResolver.php create mode 100644 tests/EventListener/SchemaRequestListenerTest.php create mode 100644 tests/Messenger/Middleware/SchemaMiddlewareTest.php diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..2a1d8cc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + run: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.3' + - '8.4' + coverage: ['none'] + symfony-versions: + - '6.4.*' + - '7.0.*' + include: + - description: 'Log Code Coverage' + php: '8.3' + symfony-versions: '^7.0' + doctrine-orm-versions: '^3.0' + coverage: xdebug + + name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: ~/.composer/cache/files + key: ${{ matrix.php }}-${{ matrix.symfony-versions }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Add PHPUnit matcher + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Set composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer + + - name: Update Symfony version + if: matrix.symfony-versions != '' + run: | + composer require symfony/framework-bundle:${{ matrix.symfony-versions }} --no-update --no-scripts + composer require --dev symfony/yaml:${{ matrix.symfony-versions }} --no-update --no-scripts + + - name: Install dependencies + run: composer install + + - name: Run PHPUnit tests + run: vendor/bin/phpunit + if: matrix.coverage == 'none' + + - name: PHPUnit tests and Log Code coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + if: matrix.coverage == 'xdebug' + + - name: Upload coverage reports to Codecov + if: matrix.coverage == 'xdebug' + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml new file mode 100644 index 0000000..248d5d0 --- /dev/null +++ b/.github/workflows/security.yaml @@ -0,0 +1,24 @@ +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + security-checker: + name: Security checker + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Download local-php-security-checker + run: curl -s -L -o local-php-security-checker https://github.com/fabpot/local-php-security-checker/releases/download/v1.0.0/local-php-security-checker_1.0.0_linux_amd64 + + - name: Run local-php-security-checker + run: chmod +x local-php-security-checker && ./local-php-security-checker diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml new file mode 100644 index 0000000..8796ae1 --- /dev/null +++ b/.github/workflows/static-analysis.yaml @@ -0,0 +1,55 @@ +name: Code style and static analysis + +on: + pull_request: + push: + branches: [ main, develop ] + +jobs: + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpcs + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpstan analyse + + composer-validate: + name: Composer validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer composer-validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1473148 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +/vendor/ +/composer.lock +/.phpcs-cache + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### diff --git a/README.md b/README.md index 4e65f43..dbf53bb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ -# schema-context-bundle -Schema context bundle +# Schema Context Bundle + +The **SchemaContextBundle** provides a lightweight way to manage dynamic schema context across your Symfony application, especially useful for multi-tenant setups. It allows schema resolution based on request headers and propagates schema information through Symfony Messenger. + +--- + +## Features + +- Extracts tenant schema from request headers. +- Stores schema context in a global `SchemaResolver`. +- Injects schema info into Messenger messages via a middleware. +- Rehydrates schema on message consumption via a middleware. + +--- + +## Installation + +```bash +composer require macpaw/schema-context-bundle +``` + +If you are not using Symfony Flex, register the bundle manually: + +```php +// config/bundles.php +return [ + Macpaw\SchemaContextBundle\SchemaContextBundle::class => ['all' => true], +]; +``` +## Configuration +### 1. Bundle Configuration +Add this config to `config/packages/schema_context.yaml`: + +```yaml +schema_context: + app_name: '%env(APP_NAME)%' # Application name + header_name: 'X-Tenant' # Request header to extract schema name + default_schema: 'public' # Default schema to fallback to + allowed_app_names: ['develop', 'staging', 'test'] # App names where schema context is allowed to change +``` +### 2. Set Environment Parameters +If you're using .env, define the app name: + +```env +APP_NAME=develop +``` + +## Usage + +```php +use Macpaw\SchemaContextBundle\Service\SchemaResolver; + +public function index(SchemaResolver $schemaResolver) +{ + $schema = $schemaResolver->getSchema(); + // Use schema in logic +} +``` + +## Messenger Integration +The bundle provides a middleware that automatically: + +* Adds a SchemaStamp to dispatched messages + +* Restores the schema context on message handling + +Enable the middleware in your `messenger.yaml`: + +```yaml +framework: + messenger: + buses: + messenger.bus.default: + middleware: + - Macpaw\SchemaContextBundle\Messenger\Middleware\SchemaMiddleware +``` + +## Testing +To run tests: +```bash +vendor/bin/phpunit +``` + +## Contributing +Feel free to open issues and submit pull requests. + +## License +This bundle is released under the MIT license. + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0b6878f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues +If you believe you have found a security vulnerability in any MacPaw-owned repository, please report it to us through coordinated disclosure. + +Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests. + +Instead, please send an email to security[@]macpaw.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy +See MacPaw's [Vulnerability Disclosure Policy](https://macpaw.com/vulnerability-disclosure-policy) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..37a7d71 --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "macpaw/schema-context-bundle", + "description": "A Symfony bundle to provide schema context", + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Macpaw\\SchemaContextBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Macpaw\\SchemaContextBundle\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "3.7.*" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "composer-validate": [ + "composer validate" + ], + "cs": [ + "vendor/bin/phpcs" + ], + "cs-fix": [ + "vendor/bin/phpcbf" + ], + "phpstan": [ + "vendor/bin/phpstan analyse" + ], + "phpunit": [ + "vendor/bin/phpunit" + ] + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..2acb1ef --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,23 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Macpaw\SchemaContextBundle\Service\SchemaResolver: + public: true + shared: true + + Macpaw\SchemaContextBundle\EventListener\SchemaRequestListener: + arguments: + $schemaResolver: '@Macpaw\SchemaContextBundle\Service\SchemaResolver' + $schemaRequestHeader: '%schema_context.header_name%' + $defaultSchema: '%schema_context.default_schema%' + $appName: '%schema_context.app_name%' + $allowedAppNames: '%schema_context.allowed_app_names%' + tags: + - { name: kernel.event_subscriber } + + Macpaw\SchemaContextBundle\Messenger\Middleware\SchemaMiddleware: + tags: + - { name: messenger.middleware } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..d89bc03 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + src/ + tests/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f6ecc8a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + - + message: '~Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).~' + count: 1 + path: ./src/DependencyInjection diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6344d8d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./tests + + + + + + + + + + ./src + + + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..f68fdc9 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,29 @@ +getRootNode() + ->children() + ->scalarNode('header_name')->defaultValue('X-Schema')->end() + ->scalarNode('default_schema')->defaultValue('public')->end() + ->scalarNode('app_name')->isRequired()->cannotBeEmpty()->end() + ->arrayNode('allowed_app_names') + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/SchemaContextExtension.php b/src/DependencyInjection/SchemaContextExtension.php new file mode 100644 index 0000000..f2a578f --- /dev/null +++ b/src/DependencyInjection/SchemaContextExtension.php @@ -0,0 +1,28 @@ +processConfiguration($configuration, $configs); + + $container->setParameter('schema_context.header_name', $config['header_name']); + $container->setParameter('schema_context.default_schema', $config['default_schema']); + $container->setParameter('schema_context.app_name', $config['app_name']); + $container->setParameter('schema_context.allowed_app_names', $config['allowed_app_names']); + + $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); + + $loader->load('services.yaml'); + } +} diff --git a/src/EventListener/SchemaRequestListener.php b/src/EventListener/SchemaRequestListener.php new file mode 100644 index 0000000..aa40ea5 --- /dev/null +++ b/src/EventListener/SchemaRequestListener.php @@ -0,0 +1,46 @@ + 'onKernelRequest']; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$this->isAllowedAppName()) { + return; + } + + $schema = $event->getRequest()->headers->get($this->schemaRequestHeader, $this->defaultSchema); + + if ($schema !== null && $schema !== '') { + $this->schemaResolver->setSchema($schema); + } + } + + private function isAllowedAppName(): bool + { + return in_array($this->appName, $this->allowedAppNames, true); + } +} diff --git a/src/Messenger/Middleware/SchemaMiddleware.php b/src/Messenger/Middleware/SchemaMiddleware.php new file mode 100644 index 0000000..78fe890 --- /dev/null +++ b/src/Messenger/Middleware/SchemaMiddleware.php @@ -0,0 +1,37 @@ +last(SchemaStamp::class); + + if ($stamp instanceof SchemaStamp) { + $this->schemaResolver->setSchema($stamp->schema); + + return $stack->next()->handle($envelope, $stack); + } + + $schema = $this->schemaResolver->getSchema(); + + if ($schema !== null && $schema !== '') { + $envelope = $envelope->with(new SchemaStamp($schema)); + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Messenger/Stamp/SchemaStamp.php b/src/Messenger/Stamp/SchemaStamp.php new file mode 100644 index 0000000..ac5fd3c --- /dev/null +++ b/src/Messenger/Stamp/SchemaStamp.php @@ -0,0 +1,14 @@ +schema = $schema; + } + + public function getSchema(): ?string + { + return $this->schema; + } +} diff --git a/tests/EventListener/SchemaRequestListenerTest.php b/tests/EventListener/SchemaRequestListenerTest.php new file mode 100644 index 0000000..baf2658 --- /dev/null +++ b/tests/EventListener/SchemaRequestListenerTest.php @@ -0,0 +1,55 @@ + 'tenant1']); + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $listener->onKernelRequest($event); + + self::assertSame('tenant1', $resolver->getSchema()); + } + + public function testDefaultSchemaIsUsedIfHeaderMissing(): void + { + $resolver = new SchemaResolver(); + $listener = new SchemaRequestListener( + $resolver, + 'X-Schema', + 'fallback', + 'test-app', + ['test-app'], + ); + + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $listener->onKernelRequest($event); + + self::assertSame('fallback', $resolver->getSchema()); + } +} diff --git a/tests/Messenger/Middleware/SchemaMiddlewareTest.php b/tests/Messenger/Middleware/SchemaMiddlewareTest.php new file mode 100644 index 0000000..0b6fcd4 --- /dev/null +++ b/tests/Messenger/Middleware/SchemaMiddlewareTest.php @@ -0,0 +1,67 @@ +with($stamp); + $stack = $this->createMock(StackInterface::class); + $nextMiddleware = new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $envelope; + } + }; + + $stack->expects($this->once()) + ->method('next') + ->willReturn($nextMiddleware); + + $middleware->handle($envelope, $stack); + + $this->assertSame('tenant1', $resolver->getSchema()); + } + + public function testSchemaStampIsInjectedIfMissing(): void + { + $schema = 'tenant1'; + $resolver = new SchemaResolver(); + $resolver->setSchema($schema); + $middleware = new SchemaMiddleware($resolver); + $originalEnvelope = new Envelope(new \stdClass()); + $stack = $this->createMock(StackInterface::class); + + $stack->expects($this->once()) + ->method('next') + ->willReturnCallback(function () { + return new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $envelope; + } + }; + }); + + $resultEnvelope = $middleware->handle($originalEnvelope, $stack); + + $stamp = $resultEnvelope->last(SchemaStamp::class); + + $this->assertInstanceOf(SchemaStamp::class, $stamp); + $this->assertSame($schema, $stamp->schema); + } +}