From f0d4eaa6089bdb5e54d10bbf5c3e5610ec7d9cac Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 20 Feb 2026 13:06:18 +0300 Subject: [PATCH 1/3] [feat] initial release of chamber-orchestra/openapi-doc-bundle v8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-generates OpenAPI 3.0.1 YAML for ADR-pattern Symfony apps - Namespace: ChamberOrchestra\OpenApiDocBundle - PHP 8.5+, Symfony 8.x - Generation pipeline: Locator → OperationDescriber → ComponentDescriber → DocumentBuilder - FormTypeHandler strategy pattern (11 handlers) - TypeConverter for PHP→OpenAPI type mapping - Security via #[IsGranted], proto.yaml merge - CI workflow, .gitignore, composer.lock Co-Authored-By: Claude Sonnet 4.6 --- .../code-improvement-reviewer/MEMORY.md | 60 + .claude/agents/code-improvement-reviewer.md | 137 + .github/workflows/php.yml | 43 + .github/workflows/tag.yml | 46 + .gitignore | 3 + AGENTS.md | 94 + CLAUDE.md | 108 + LICENSE | 201 + README.md | 501 +- composer.json | 75 + composer.lock | 5730 +++++++++++++++++ php-cs-fixer.dist.php | 35 + phpunit.xml.dist | 21 + src/Attribute/Model.php | 15 + src/Attribute/Operation.php | 15 + src/Attribute/Property.php | 15 + src/Builder/DocumentBuilder.php | 88 + src/Command/AbstractCommand.php | 20 + src/Command/OpenApiDocGenerator.php | 104 + .../OpenApiDocExtension.php | 18 + src/Describer/AbstractDescriber.php | 44 + src/Describer/ComponentDescriber.php | 42 + src/Describer/DescriberInterface.php | 17 + src/Describer/OperationDescriber.php | 46 + src/Exception/DescriberException.php | 9 + src/Exception/Exception.php | 9 + src/Locator/Locator.php | 75 + src/Model/Component.php | 21 + src/Model/Model.php | 10 + src/Model/Operation.php | 18 + src/Model/Property.php | 38 + src/OpenApiDocBundle.php | 11 + src/Parser/ComponentParserInterface.php | 9 + src/Parser/FormParser.php | 82 + .../AbstractFormTypeHandler.php | 104 + .../FormTypeHandler/CheckboxTypeHandler.php | 21 + .../FormTypeHandler/ChoiceTypeHandler.php | 58 + .../FormTypeHandler/CollectionTypeHandler.php | 49 + .../FormTypeHandler/DateTimeTypeHandler.php | 22 + .../FormTypeHandler/DateTypeHandler.php | 22 + .../FormTypeHandler/EntityTypeHandler.php | 31 + .../FormTypeHandler/FileTypeHandler.php | 21 + .../FormTypeHandlerInterface.php | 16 + .../FormTypeHandler/NumberTypeHandler.php | 58 + .../FormTypeHandler/PasswordTypeHandler.php | 22 + .../FormTypeHandler/RepeatedTypeHandler.php | 48 + .../FormTypeHandler/TextTypeHandler.php | 47 + src/Parser/ObjectParser.php | 90 + src/Parser/OperationParser.php | 67 + src/Parser/OperationParserInterface.php | 9 + src/Parser/ParserInterface.php | 20 + src/Parser/PropertyParser.php | 25 + src/Parser/ResponseParser.php | 31 + src/Parser/RouteParser.php | 59 + src/Parser/SecurityParser.php | 27 + src/Parser/ViewParser.php | 216 + src/Registry/ComponentRegistry.php | 84 + src/Registry/OperationRegistry.php | 118 + src/Registry/Registry.php | 24 + src/Registry/RegistryInterface.php | 14 + src/Utils/ClassParser.php | 50 + src/Utils/TypeConverter.php | 60 + src/config/services.yaml | 51 + .../Action/ActionWithoutOperation.php | 13 + tests/Fixtures/Action/ActionWithoutRoute.php | 13 + tests/Fixtures/Action/SimpleAction.php | 19 + tests/Fixtures/Dto/NestedDto.php | 11 + tests/Fixtures/Dto/SimpleDto.php | 13 + tests/Fixtures/Enum/Priority.php | 12 + tests/Fixtures/Form/ChoiceFormType.php | 24 + tests/Fixtures/Form/CollectionFormType.php | 21 + tests/Fixtures/Form/ConstrainedFormType.php | 41 + tests/Fixtures/Form/EnumChoiceFormType.php | 39 + tests/Fixtures/Form/NestedFormType.php | 19 + tests/Fixtures/Form/RecursiveFormType.php | 26 + tests/Fixtures/Form/SimpleFormType.php | 20 + tests/Fixtures/View/IterableContainerView.php | 17 + tests/Fixtures/View/NestedView.php | 13 + tests/Fixtures/View/RecursiveView.php | 21 + tests/Fixtures/View/SimpleView.php | 15 + .../AbstractIntegrationTestCase.php | 43 + tests/Integrational/Parser/FormParserTest.php | 360 ++ .../Integrational/Parser/ObjectParserTest.php | 114 + tests/Integrational/Parser/ViewParserTest.php | 177 + tests/Unit/Builder/DocumentBuilderTest.php | 177 + tests/Unit/Locator/LocatorTest.php | 96 + .../FormTypeHandler/ChoiceTypeHandlerTest.php | 147 + .../FormTypeHandler/NumberTypeHandlerTest.php | 122 + .../FormTypeHandler/TextTypeHandlerTest.php | 134 + tests/Unit/Registry/ComponentRegistryTest.php | 218 + tests/Unit/Registry/OperationRegistryTest.php | 176 + tests/Unit/Utils/TypeConverterTest.php | 98 + tests/bootstrap.php | 5 + 93 files changed, 11427 insertions(+), 1 deletion(-) create mode 100644 .claude/agent-memory/code-improvement-reviewer/MEMORY.md create mode 100644 .claude/agents/code-improvement-reviewer.md create mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/tag.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 php-cs-fixer.dist.php create mode 100644 phpunit.xml.dist create mode 100644 src/Attribute/Model.php create mode 100644 src/Attribute/Operation.php create mode 100644 src/Attribute/Property.php create mode 100644 src/Builder/DocumentBuilder.php create mode 100644 src/Command/AbstractCommand.php create mode 100644 src/Command/OpenApiDocGenerator.php create mode 100644 src/DependencyInjection/OpenApiDocExtension.php create mode 100644 src/Describer/AbstractDescriber.php create mode 100644 src/Describer/ComponentDescriber.php create mode 100644 src/Describer/DescriberInterface.php create mode 100644 src/Describer/OperationDescriber.php create mode 100644 src/Exception/DescriberException.php create mode 100644 src/Exception/Exception.php create mode 100644 src/Locator/Locator.php create mode 100644 src/Model/Component.php create mode 100644 src/Model/Model.php create mode 100644 src/Model/Operation.php create mode 100644 src/Model/Property.php create mode 100644 src/OpenApiDocBundle.php create mode 100644 src/Parser/ComponentParserInterface.php create mode 100644 src/Parser/FormParser.php create mode 100644 src/Parser/FormTypeHandler/AbstractFormTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/CheckboxTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/ChoiceTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/CollectionTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/DateTimeTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/DateTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/EntityTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/FileTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/FormTypeHandlerInterface.php create mode 100644 src/Parser/FormTypeHandler/NumberTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/PasswordTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/RepeatedTypeHandler.php create mode 100644 src/Parser/FormTypeHandler/TextTypeHandler.php create mode 100644 src/Parser/ObjectParser.php create mode 100644 src/Parser/OperationParser.php create mode 100644 src/Parser/OperationParserInterface.php create mode 100644 src/Parser/ParserInterface.php create mode 100644 src/Parser/PropertyParser.php create mode 100644 src/Parser/ResponseParser.php create mode 100644 src/Parser/RouteParser.php create mode 100644 src/Parser/SecurityParser.php create mode 100644 src/Parser/ViewParser.php create mode 100644 src/Registry/ComponentRegistry.php create mode 100644 src/Registry/OperationRegistry.php create mode 100644 src/Registry/Registry.php create mode 100644 src/Registry/RegistryInterface.php create mode 100644 src/Utils/ClassParser.php create mode 100644 src/Utils/TypeConverter.php create mode 100644 src/config/services.yaml create mode 100644 tests/Fixtures/Action/ActionWithoutOperation.php create mode 100644 tests/Fixtures/Action/ActionWithoutRoute.php create mode 100644 tests/Fixtures/Action/SimpleAction.php create mode 100644 tests/Fixtures/Dto/NestedDto.php create mode 100644 tests/Fixtures/Dto/SimpleDto.php create mode 100644 tests/Fixtures/Enum/Priority.php create mode 100644 tests/Fixtures/Form/ChoiceFormType.php create mode 100644 tests/Fixtures/Form/CollectionFormType.php create mode 100644 tests/Fixtures/Form/ConstrainedFormType.php create mode 100644 tests/Fixtures/Form/EnumChoiceFormType.php create mode 100644 tests/Fixtures/Form/NestedFormType.php create mode 100644 tests/Fixtures/Form/RecursiveFormType.php create mode 100644 tests/Fixtures/Form/SimpleFormType.php create mode 100644 tests/Fixtures/View/IterableContainerView.php create mode 100644 tests/Fixtures/View/NestedView.php create mode 100644 tests/Fixtures/View/RecursiveView.php create mode 100644 tests/Fixtures/View/SimpleView.php create mode 100644 tests/Integrational/AbstractIntegrationTestCase.php create mode 100644 tests/Integrational/Parser/FormParserTest.php create mode 100644 tests/Integrational/Parser/ObjectParserTest.php create mode 100644 tests/Integrational/Parser/ViewParserTest.php create mode 100644 tests/Unit/Builder/DocumentBuilderTest.php create mode 100644 tests/Unit/Locator/LocatorTest.php create mode 100644 tests/Unit/Parser/FormTypeHandler/ChoiceTypeHandlerTest.php create mode 100644 tests/Unit/Parser/FormTypeHandler/NumberTypeHandlerTest.php create mode 100644 tests/Unit/Parser/FormTypeHandler/TextTypeHandlerTest.php create mode 100644 tests/Unit/Registry/ComponentRegistryTest.php create mode 100644 tests/Unit/Registry/OperationRegistryTest.php create mode 100644 tests/Unit/Utils/TypeConverterTest.php create mode 100644 tests/bootstrap.php diff --git a/.claude/agent-memory/code-improvement-reviewer/MEMORY.md b/.claude/agent-memory/code-improvement-reviewer/MEMORY.md new file mode 100644 index 0000000..5f3acc2 --- /dev/null +++ b/.claude/agent-memory/code-improvement-reviewer/MEMORY.md @@ -0,0 +1,60 @@ +# Code Improvement Reviewer Memory + +## Project: dev/openapi-adr-bundle + +### Architecture (post-refactor, confirmed 2026-02-20) +- Symfony bundle that auto-generates OpenAPI 3.0.1 docs from PHP attributes on ADR action classes +- Generation pipeline: Locator → OperationDescriber → ComponentDescriber → Registries → DocumentBuilder → YAML +- Parser system uses tagged Symfony services (OperationParserInterface / ComponentParserInterface) +- Key entry point: `src/Command/ApiDocGenerator.php` (console command `api-doc:generate`) +- `DocumentBuilder` (`src/Builder/DocumentBuilder.php`) owns proto-merge and security-scheme alias resolution +- FormTypeHandler strategy pattern in `src/Parser/FormTypeHandler/` (13 files: interface + abstract + 11 handlers) + +### Issues Fixed Since First Review (2026-02-19 → 2026-02-20) +All issues from the first review have been resolved in the refactor: +- ComponentDescriber now uses injected registry correctly +- Locator is injectable via DI +- OperationDescriber getItemsToParse now passes string to class_exists correctly +- ViewParser convertType float bug fixed +- RouteParser now throws InvalidArgumentException on empty methods array +- Property::$required is now typed bool|array|null +- declare(strict_types=1) added to all files +- Russian comments removed +- Constraint.php and ModelParser.php deleted + +### Remaining Issues Found (second review, 2026-02-20) + +**HIGH — correctness bugs:** +- `ComponentRegistry::propertyToArray()` emits `required` as a property-level field (invalid OAS 3.0). + required is a schema-level array in OpenAPI 3.0; writing it per-property produces invalid YAML. +- `dispatch()` duplicated verbatim in CollectionTypeHandler (line 50) and RepeatedTypeHandler (line 49); + `getBuiltinFormType()` duplicated as third copy in FormParser (line 83) — three-way DRY violation. + +**MEDIUM — design issues:** +- `Component::$excluded` flag is set as a side-effect in OperationRegistry::requestToQueryParams() (line 73), + creating hidden temporal coupling: OperationRegistry::getAll() MUST run before ComponentRegistry::getAll(). + Fix: track excluded IDs inside OperationRegistry, pass to ComponentRegistry via DocumentBuilder. +- `Property::$required` carries bool|array|null where bool="required in parent" and array="sub-property required list" — + two unrelated concepts in one field; caused the invalid-OAS bug above. + Fix: split into $required ?bool and $requiredProperties array. +- ViewParser injects concrete ComponentDescriber instead of DescriberInterface (line 27). + ResponseParser has same issue (line 14). All other parsers correctly use the interface. +- AbstractDescriber::describe() runs ALL matching parsers per item; if two parsers support the same item, + second silently overwrites first. Convention currently makes them mutually exclusive but not enforced. + Fix: add `break` after first matching parser (option A) or document additive contract (option B). + +**LOW — minor issues:** +- Operation::$path and $method declared ?string but default '' — should be plain string. +- OperationRegistry::getAll() has no guard for empty path/method on registered operations. +- NumberTypeHandler: Range constraint check runs after GreaterThan chain and silently overrides it. +- ChoiceTypeHandler reads getOption('multiple') twice (lines 20 and 26) — extract to local variable. +- PropertyParser is injected as concrete class in ViewParser and ObjectParser — no interface. + +### Code Patterns (current state) +- Models are plain mutable public-property objects (no encapsulation) — accepted pattern for this bundle +- `/* @var */` docblock casts still used in parsers for $model and $item type narrowing +- No tests present in the repository +- TypeConverter is a clean pure-function utility with no side effects +- Property::factory() named constructor used consistently across all parsers +- FormTypeHandler pattern: one class per Symfony form block prefix, all extend AbstractFormTypeHandler +- All services tagged correctly: dev_api_doc.component_parser, dev_api_doc.operation_parser, dev_api_doc.form_type_handler diff --git a/.claude/agents/code-improvement-reviewer.md b/.claude/agents/code-improvement-reviewer.md new file mode 100644 index 0000000..7a3632d --- /dev/null +++ b/.claude/agents/code-improvement-reviewer.md @@ -0,0 +1,137 @@ +--- +name: code-improvement-reviewer +description: "Use this agent when you need comprehensive code quality review and improvement suggestions for backend code. Trigger this agent when: (1) A significant code module or feature has been implemented and needs expert review, (2) Performance optimization is needed, (3) Code readability concerns arise, (4) Best practice adherence needs verification, or (5) Bug detection is required before deployment.\\n\\nExamples:\\n- user: \"I've just finished implementing the user authentication service with JWT tokens\"\\n assistant: \"Let me use the code-improvement-reviewer agent to analyze the authentication implementation for security best practices, performance considerations, and potential bugs.\"\\n Since significant authentication code was written, proactively use the code-improvement-reviewer agent to ensure security and quality standards are met.\\n\\n- user: \"Can you review the database query optimizations I made in the OrderRepository?\"\\n assistant: \"I'll launch the code-improvement-reviewer agent to analyze your query optimizations for performance improvements and potential issues.\"\\n User explicitly requested code review, so use the code-improvement-reviewer agent rather than responding directly.\\n\\n- user: \"I've refactored the payment processing module\"\\n assistant: \"Since you've completed a refactoring of critical payment code, let me use the code-improvement-reviewer agent to verify the changes maintain correctness and follow best practices.\"\\n Critical business logic was modified, proactively trigger code review for safety." +tools: Glob, Grep, Read, WebFetch, WebSearch +model: sonnet +color: green +memory: project +--- + +You are a distinguished Senior Backend Engineer with 15+ years of experience across multiple languages, frameworks, and architectural patterns. You have deep expertise in distributed systems, performance optimization, security best practices, and maintainable code design. Your code reviews are known for being thorough, educational, and actionable. + +**Your Core Responsibilities:** + +1. **Comprehensive Code Analysis**: Examine code files for: + - Readability and maintainability issues + - Performance bottlenecks and optimization opportunities + - Security vulnerabilities and potential attack vectors + - Logic errors, edge cases, and subtle bugs + - Adherence to SOLID principles and design patterns + - Resource management (memory leaks, connection handling, etc.) + - Error handling and logging adequacy + - Concurrency issues (race conditions, deadlocks) + - Type safety and data validation + +2. **Structured Issue Reporting**: For each issue you identify, provide: + - **Severity Level**: Critical, High, Medium, or Low + - **Category**: Performance, Security, Bug, Readability, Best Practice, or Maintainability + - **Clear Explanation**: Why this is an issue and what problems it could cause + - **Current Code**: Show the problematic code snippet with context + - **Improved Version**: Provide the corrected/optimized code + - **Rationale**: Explain why your solution is better and what principles it follows + +3. **Educational Approach**: Don't just point out problems—teach. Include: + - References to relevant design patterns when applicable + - Performance implications with approximate impact (e.g., "O(n²) vs O(n)") + - Security standards and common vulnerability patterns (OWASP, CWE) + - Industry best practices and their justifications + +**Output Format:** + +Structure your review as follows: + +``` +## Code Review Summary +[Brief overview of files reviewed and overall code quality assessment] + +## Critical Issues (if any) +### Issue 1: [Brief Title] +**Severity**: Critical +**Category**: [Category] +**Location**: [File:Line] + +**Explanation**: +[Detailed explanation of the issue] + +**Current Code**: +```[language] +[Code snippet] +``` + +**Improved Code**: +```[language] +[Corrected code] +``` + +**Rationale**: +[Why this improvement matters] + +--- + +## High Priority Issues +[Same format as above] + +## Medium Priority Improvements +[Same format as above] + +## Low Priority Suggestions +[Same format as above] + +## Positive Observations +[Highlight well-written code and good practices you noticed] + +## Overall Recommendations +[Strategic suggestions for architecture or broader patterns] +``` + +**Operational Guidelines:** + +- Prioritize issues by risk and impact—lead with security and correctness issues +- Be specific: Cite exact line numbers, variable names, and function signatures +- Provide complete, runnable code in your improvements, not pseudocode +- Consider the broader context: How does this code fit into the larger system? +- Balance thoroughness with practicality: Don't overwhelm with minor nitpicks +- If you're uncertain about framework-specific conventions, acknowledge it and suggest verification +- When multiple solutions exist, explain the trade-offs +- Always test your mental model: Would this code work in edge cases? + +**Quality Assurance:** + +- Before suggesting improvements, verify they actually solve the problem +- Ensure your improved code maintains the original functionality +- Check that your suggestions don't introduce new issues +- Consider backward compatibility and breaking changes +- Validate that performance improvements are meaningful, not micro-optimizations + +**Update your agent memory** as you discover code patterns, architectural decisions, framework conventions, common issues, and team coding standards in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Recurring patterns ("Uses repository pattern with dependency injection in services/") +- Architectural decisions ("Microservices communicate via RabbitMQ, not direct HTTP") +- Security patterns ("All user input validated with Joi schemas in validators/") +- Performance characteristics ("Database queries in OrderService are well-optimized with proper indexes") +- Code style preferences ("Team uses functional programming style, prefers immutability") +- Common issues ("Date handling inconsistent - mix of Date objects and Unix timestamps") +- Testing conventions ("Integration tests in /tests/integration, mocks in /tests/__mocks__") +- Library locations and purposes ("util/logger.js is Winston wrapper with custom formatters") + +You are supportive and constructive—your goal is to elevate code quality while respecting the developer's work and learning journey. + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `./view-bundle/.claude/agent-memory/code-improvement-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Record insights about problem constraints, strategies that worked or failed, and lessons learned +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..35d466f --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: ["main", "8.0"] + pull_request: + branches: ["main", "8.0"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer:v2 + coverage: none + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v5 + with: + path: | + vendor + ~/.composer/cache/files + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-8.5-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run test suite + run: composer test diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..bcc66b0 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,46 @@ +name: Tag Release + +on: + pull_request: + types: [closed] + branches: [main, "8.0"] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + fetch-tags: true + + - name: Get latest tag and compute next patch version + id: version + run: | + latest=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$latest" ]; then + echo "next=v8.0.0" >> "$GITHUB_OUTPUT" + else + major=$(echo "$latest" | cut -d. -f1) + minor=$(echo "$latest" | cut -d. -f2) + patch=$(echo "$latest" | cut -d. -f3) + next_patch=$((patch + 1)) + echo "next=${major}.${minor}.${next_patch}" >> "$GITHUB_OUTPUT" + fi + echo "Latest tag: ${latest:-none}, next: $(grep next "$GITHUB_OUTPUT" | cut -d= -f2)" + + - name: Create and push tag + run: | + next="${{ steps.version.outputs.next }}" + if [[ -z "$next" ]]; then + echo "::error::Version computation produced an empty tag — aborting" + exit 1 + fi + git tag "$next" + git push origin "$next" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aecee12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +.idea/ +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..88f941d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# Repository Guidelines + +## Project Overview + +`chamber-orchestra/openapi-doc-bundle` is a Symfony bundle that auto-generates OpenAPI 3.0.1 documentation by scanning PHP source files for action classes annotated with `#[Operation]` and `#[Route]` attributes. It targets the **Action-Domain-Responder (ADR)** pattern where each API endpoint is an invokable class. + +- Namespace: `ChamberOrchestra\OpenApiDocBundle\` +- Autoloading: PSR-4 from `src/` +- Requirements: PHP 8.5+, Symfony 8.x components + +## Project Structure + +``` +src/ + ApiDocBundle.php — bundle entry point + Attribute/ — #[Operation], #[Property], #[Model] + Command/ — ApiDocGenerator console command + config/services.yaml — service definitions and parser tags + DependencyInjection/ — bundle extension (config loading) + Describer/ — OperationDescriber, ComponentDescriber + Exception/ — DescriberException, Exception + Locator/ — Locator (scans src for action classes) + Model/ — Operation, Component, Property, Constraint, ModelFactory + Parser/ — operation and component parsers + Registry/ — OperationRegistry, ComponentRegistry + Utils/ — ClassParser helper +``` + +## Build & Development Commands + +```bash +# Install dependencies +composer install + +# Generate OpenAPI documentation +php bin/console openapi-doc:generate \ + [--src ] # default: /src + [--output ] # default: /doc.yaml + [--proto ] # default: /proto.yaml + [--title ] # default: "API Documentation" + [--doc-version ] # default: "1.0.0" + +# Lint a file +php -l src/Path/To/File.php +``` + +## Generation Pipeline + +1. **`Locator`** — scans `src/` recursively for classes with **both** `#[Operation]` and `#[Route]` attributes. +2. **`OperationDescriber`** — for each action class, collects PHP attributes and `__invoke` return types, runs all `OperationParserInterface` parsers, registers the result in `OperationRegistry`. +3. **`ComponentDescriber`** — called lazily when a parser encounters a class needing an OpenAPI schema component; runs `ComponentParserInterface` parsers, registers in `ComponentRegistry`. +4. **Registries** serialize to OpenAPI structure: `OperationRegistry::toArray()` → `paths`, `ComponentRegistry::toArray()` → `components.schemas`. +5. **`ApiDocGenerator` command** merges registry output with optional `proto.yaml` and writes the final YAML. + +## Parser System + +Each parser implements `supports(object $item): bool` and `parse(Model $model, object $item): Model`. + +**Operation parsers** (tag: `openapi_doc.operation_parser`): +- `RouteParser` — extracts `path`, `method`, `operationId` from `#[Route]` +- `OperationParser` — reads `#[Operation]` for description, request body, responses, security overrides +- `ResponseParser` — triggered by `ReflectionClass` return types from `__invoke`; delegates to `ComponentDescriber` +- `SecurityParser` — sets default security when `#[Security]` or `#[IsGranted]` is present +- `ModelParser` — stub for `#[Model]` (currently a no-op) + +**Component parsers** (tag: `openapi_doc.component_parser`): +- `FormParser` — handles Symfony `FormTypeInterface` classes; maps field types and validator constraints to OpenAPI properties +- `ViewParser` — handles classes implementing `Dev\ViewBundle\View\ViewInterface`; recursively describes nested objects +- `ObjectParser` — fallback for plain DTOs; reads public properties and PHP types + +## Attributes (used in consuming application) + +- `#[Operation(description, request, responses, security)]` — class-level; marks an action as a documented endpoint +- `#[Property(required, attr)]` — property-level on DTOs; sets required flag and extra OpenAPI attributes +- `#[Model(model, type)]` — class-level; currently unused in parsers + +## Coding Style + +- Follow PSR-12: 4-space indent, one class per file, `declare(strict_types=1)`. +- Use typed properties and return types; favor `readonly` where appropriate. +- Keep constructors light; prefer small, composable services injected via Symfony DI. +- `php-cs-fixer.dist.php` is present — run PHP CS Fixer before committing if available. + +## proto.yaml + +A hand-authored YAML merged at the end of generation: +- `components.securitySchemes` — first scheme becomes default when `#[Security]` is detected +- `components.schemas` — merged with generated schemas +- `components.responses` — shared response refs used in `#[Operation](responses: [...])` + +## Commit Guidelines + +- Commit messages: short, action-oriented, optionally bracketed scope (e.g., `[fix] handle missing route`, `[feat] add form parser`). +- Keep commits focused; avoid unrelated formatting churn. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c200d6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`chamber-orchestra/openapi-doc-bundle` is a Symfony bundle that auto-generates OpenAPI 3.0.1 documentation by scanning PHP source files for action classes annotated with `#[Operation]` and `#[Route]` attributes. It is designed for the **Action-Domain-Responder (ADR)** pattern where each API endpoint is an invokable class. + +## Commands + +### Generate documentation +```bash +php bin/console openapi-doc:generate \ + [--src ] # default: /src \ + [--output ] # default: /doc.yaml \ + [--proto ] # default: /proto.yaml \ + [--title ] # default: "API Documentation" \ + [--doc-version ]# default: "1.0.0" +``` + +### Install dependencies +```bash +composer install +``` + +## Architecture + +### Generation pipeline + +1. **`Locator`** (`src/Locator/Locator.php`) — scans the `src` directory recursively for classes that have **both** `#[Operation]` and `#[Route]` attributes. + +2. **`OperationDescriber`** (`src/Describer/OperationDescriber.php`) — for each located action class: + - Collects its PHP attributes and the non-interface/non-abstract return types of its `__invoke` method. + - Runs all registered `OperationParserInterface` parsers against those items (first matching parser wins per item). + - Registers the resulting `Operation` model in `OperationRegistry`. + +3. **`ComponentDescriber`** (`src/Describer/ComponentDescriber.php`) — called lazily by operation/response parsers when they encounter a class that should become an OpenAPI schema component. Runs `ComponentParserInterface` parsers and registers results in `ComponentRegistry`. Contains a cycle guard (`$inProgress`) to handle recursive schemas. + +4. **Registries** serialize their models to OpenAPI structure: + - `OperationRegistry::getAll()` → OpenAPI `paths` + - `ComponentRegistry::getAll(array $excludedIds = [])` → OpenAPI `components.schemas` + +5. **`DocumentBuilder`** (`src/Builder/DocumentBuilder.php`) — merges registry output with an optional `proto.yaml`, resolves the `'default'` security placeholder to the first defined scheme, and returns the final OpenAPI 3.0.1 array. + +6. **`ApiDocGenerator` command** serializes the DocumentBuilder output to YAML and writes it to disk. + +### Parser system + +Parsers are tagged Symfony services. Each implements `supports(object $item): bool` and `parse(Model $model, object $item): Model`. The first matching parser per item wins (no fallthrough). + +**Operation parsers** (`OperationParserInterface`, tag: `openapi_doc.operation_parser`): +- `SecurityParser` (priority: 10) — sets `security['default'] = []` when `#[IsGranted]` is present. The `'default'` key is later replaced by `DocumentBuilder` with the first scheme from `proto.yaml`. **Note: only `#[IsGranted]` is supported; `#[Security]` is not.** +- `RouteParser` — extracts `path`, `method`, `operationId` from `#[Route]` +- `OperationParser` — reads `#[Operation]` for description, request body class, responses, and optional security override +- `ResponseParser` — triggered by `ReflectionClass` items (return types from `__invoke`); delegates to `ComponentDescriber` + +**Component parsers** (`ComponentParserInterface`, tag: `openapi_doc.component_parser`): +- `FormParser` — handles Symfony `FormTypeInterface` classes; maps form field types and validator constraints to OpenAPI property definitions via `FormTypeHandler` strategy +- `ViewParser` — handles classes implementing `ChamberOrchestra\ViewBundle\View\ViewInterface`; recursively describes nested object properties +- `ObjectParser` — fallback for plain DTO classes; reads public properties and PHP types + +### FormTypeHandler strategy + +`AbstractFormTypeHandler` (`src/Parser/FormTypeHandler/`) provides shared helpers: +- `static getBuiltinFormType(ResolvedFormTypeInterface): ?ResolvedFormTypeInterface` — walks the resolved type chain to find the first Symfony built-in type +- `dispatchSubProperty(Property, FormConfigInterface, iterable, DescriberInterface)` — shared dispatch used by `CollectionTypeHandler` and `RepeatedTypeHandler` + +### Attributes (apply in consuming application) + +- `#[Operation(description, request, responses, security)]` — class-level; marks an action as a documented endpoint +- `#[Property(required, attr)]` — property-level on DTOs/views; sets required flag and arbitrary extra OpenAPI attributes +- `#[Model(model, type)]` — class-level; currently unused in parsers + +### Data flow for a typical action class + +``` +Action class (#[Route] + #[IsGranted] + #[Operation]) + └─ SecurityParser → Operation.security = ['default' => []] + └─ RouteParser → Operation.path / method / id + └─ OperationParser → Operation.description / request / responses / security override + └─ ComponentDescriber (for request class) + └─ FormParser or ObjectParser → Component schema + └─ ResponseParser (from __invoke return type) + └─ ComponentDescriber (for return type class) + └─ ViewParser or ObjectParser → Component schema + +DocumentBuilder + └─ OperationRegistry.getAll() — GET/DELETE/HEAD: request form → query params (schema excluded) + └─ ComponentRegistry.getAll(excludedIds) + └─ proto.yaml merge + └─ 'default' security → first scheme from proto.yaml securitySchemes +``` + +### proto.yaml + +A hand-authored YAML file merged at the end of generation. **Required for security to appear in the output** — without `securitySchemes` defined here, the `'default'` security placeholder is stripped and operations appear as public. + +Used to supply: +- `components.securitySchemes` — first scheme becomes the replacement for the `'default'` placeholder when `#[IsGranted]` is detected +- `components.schemas` — merged with generated schemas +- `components.responses` — shared response references used in `#[Operation](responses: [...])` as string refs + +### Key model conventions + +- `Property::$required` (`?bool`) — whether this property belongs in its parent component's `required[]` list +- `Property::$requiredProperties` (`array`) — required sub-property names for inline object schemas (type: object); set by `RepeatedTypeHandler` +- `Component::$required` (`array`) — list of required property names, emitted at schema level (valid OAS 3.0) +- `OperationRegistry` tracks `$excludedComponentIds` (GET/DELETE request form schemas expanded as query params); passed to `ComponentRegistry::getAll()` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 5d36229..f733387 100644 --- a/README.md +++ b/README.md @@ -1 +1,500 @@ -# openapi-doc \ No newline at end of file +# OpenAPI ADR Bundle + +Symfony bundle that **auto-generates OpenAPI 3.0.1 documentation** for applications built on the [Action-Domain-Responder](https://en.wikipedia.org/wiki/Action%E2%80%93domain%E2%80%93responder) (ADR) pattern. Works by scanning PHP source files for action classes annotated with `#[Operation]` and `#[Route]` attributes — no YAML configuration required. + +## Features + +- Zero-config documentation generation from PHP attributes +- Automatic schema inference from Symfony Form types (including validation constraints → OpenAPI constraints) +- Automatic schema inference from View classes (`ViewInterface`, `IterableView`) +- Automatic schema inference from plain DTO classes +- BackedEnum properties → `enum` values in schemas +- `Uuid`/`Ulid` → `{ type: string, format: uuid }` +- `DateTime`/`DateTimeImmutable` → `{ type: string, format: date-time }` +- GET/DELETE/HEAD requests: form fields automatically expanded as query parameters +- Recursive schema detection (cycle guard) +- Security via `#[IsGranted]` — no extra annotation needed + +## Requirements + +- PHP 8.5+ +- Symfony 8.x +- `chamber-orchestra/view-bundle` + +## Installation + +```bash +composer require chamber-orchestra/openapi-doc-bundle +``` + +Register the bundle in `config/bundles.php`: + +```php +return [ + // ... + ChamberOrchestra\OpenApiDocBundle\OpenApiDocBundle::class => ['all' => true], +]; +``` + +## Configuration + +### proto.yaml + +Create a `proto.yaml` file in your project root. This file is merged into the final output and **must contain `securitySchemes`** for security annotations to appear in the generated documentation: + +```yaml +# proto.yaml +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + # Shared response references (used as string refs in #[Operation]) + responses: + Unauthorized: + description: Unauthorized + NotFound: + description: Not found + Forbidden: + description: Forbidden + + # Additional schemas not generated from code + schemas: + Error: + type: object + properties: + message: + type: string +``` + +> **Note:** Without `securitySchemes` in proto.yaml, `#[IsGranted]` annotations are silently ignored and operations appear as public in the generated documentation. + +## Usage + +### 1. Annotate action classes + +Each invokable action class needs `#[Route]` and `#[Operation]`: + +```php +use ChamberOrchestra\OpenApiDocBundle\Attribute\Operation; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +#[Route('/users/{id}', methods: ['GET'])] +#[IsGranted('ROLE_USER')] +#[Operation( + description: 'Get a user by ID', + responses: [UserView::class], +)] +class GetUserAction +{ + public function __invoke(): UserView + { + // ... + } +} +``` + +### 2. POST/PUT/PATCH — request body from a Form + +```php +#[Route('/users', methods: ['POST'])] +#[IsGranted('ROLE_ADMIN')] +#[Operation( + description: 'Create a new user', + request: CreateUserForm::class, + responses: [UserView::class], +)] +class CreateUserAction +{ + public function __invoke(): UserView { /* ... */ } +} +``` + +The form fields are read at generation time and become the `requestBody` schema. Symfony validation constraints are mapped to OpenAPI constraints: + +| Constraint | OpenAPI | +|---|---| +| `NotBlank` | `required: [field]` at schema level | +| `Length(min, max)` | `minLength`, `maxLength` | +| `Range(min, max)` | `minimum`, `maximum` | +| `Positive` | `minimum: 1` | +| `GreaterThanOrEqual(n)` | `minimum: n` | +| `Count(min, max)` | `minItems`, `maxItems` (array fields) | +| `Email` | `format: email` | +| `Url` | `format: uri` | +| `ChoiceType(choices)` | `enum` values | + +#### Form example + +```php +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Validator\Constraints as Assert; + +class CreateUserForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(min: 2, max: 100), + ], + ]) + ->add('email', EmailType::class, [ + 'constraints' => [new Assert\NotBlank(), new Assert\Email()], + ]) + ->add('age', IntegerType::class, [ + 'required' => false, + 'constraints' => [new Assert\Range(min: 18, max: 120)], + ]) + ->add('role', ChoiceType::class, [ + 'choices' => ['User' => 'user', 'Admin' => 'admin', 'Moderator' => 'moderator'], + 'constraints' => [new Assert\NotBlank()], + ]); + } +} +``` + +This generates the following OpenAPI schema: + +```yaml +CreateUserForm: + type: object + required: [name, email, role] + properties: + name: + type: string + minLength: 2 + maxLength: 100 + email: + type: string + format: email + age: + type: integer + minimum: 18 + maximum: 120 + role: + type: string + enum: [user, admin, moderator] +``` + +#### Nested form (sub-form as object) + +```php +class AddressForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('street', TextType::class, ['constraints' => [new Assert\NotBlank()]]) + ->add('city', TextType::class, ['constraints' => [new Assert\NotBlank()]]) + ->add('zip', TextType::class); + } +} + +class CreateOrderForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('title', TextType::class, ['constraints' => [new Assert\NotBlank()]]) + ->add('address', AddressForm::class); // nested form → $ref + } +} +``` + +Generated schema: + +```yaml +CreateOrderForm: + type: object + required: [title] + properties: + title: + type: string + address: + $ref: '#/components/schemas/AddressForm' + +AddressForm: + type: object + required: [street, city] + properties: + street: + type: string + city: + type: string + zip: + type: string +``` + +#### Collection of sub-forms + +```php +use Symfony\Component\Form\Extension\Core\Type\CollectionType; + +class CreateInvoiceForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('number', TextType::class, ['constraints' => [new Assert\NotBlank()]]) + ->add('lines', CollectionType::class, [ + 'entry_type' => InvoiceLineForm::class, + 'constraints' => [new Assert\Count(min: 1)], + ]); + } +} +``` + +Generated schema: + +```yaml +CreateInvoiceForm: + type: object + required: [number] + properties: + number: + type: string + lines: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/InvoiceLineForm' +``` + +### 3. GET — form fields become query parameters + +GET/DELETE/HEAD actions automatically expand form fields into query parameters instead of a request body: + +```php +#[Route('/users', methods: ['GET'])] +#[IsGranted('ROLE_USER')] +#[Operation( + description: 'Search users', + request: SearchUsersForm::class, + responses: [UserListView::class], +)] +class SearchUsersAction +{ + public function __invoke(): UserListView { /* ... */ } +} +``` + +```php +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\TextType; + +class SearchUsersForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('query', TextType::class, ['required' => false]) + ->add('role', ChoiceType::class, [ + 'required' => false, + 'choices' => ['User' => 'user', 'Admin' => 'admin'], + ]) + ->add('page', IntegerType::class, ['required' => false]); + } +} +``` + +Each field becomes an individual query parameter: + +```yaml +parameters: + - name: query + in: query + schema: + type: string + - name: role + in: query + schema: + type: string + enum: [user, admin] + - name: page + in: query + schema: + type: integer +``` + +### 4. Multiple responses (including shared references from proto.yaml) + +```php +#[Operation( + description: 'Update user', + request: UpdateUserForm::class, + responses: [ + UserView::class, // Described as a component schema + '404' => 'NotFound', // String ref → #/components/responses/NotFound + '403' => 'Forbidden', // String ref → #/components/responses/Forbidden + ], +)] +``` + +### 5. Custom security + +Override security for a specific operation (e.g., API key instead of the default Bearer): + +```php +#[Operation( + description: 'Webhook endpoint', + security: ['ApiKeyAuth' => []], +)] +``` + +Disable security for a public endpoint: + +```php +#[Operation( + description: 'Public endpoint', + security: [], +)] +``` + +### 6. Annotating DTO properties + +Use `#[Property]` to mark a property as required or add arbitrary OpenAPI attributes: + +```php +use ChamberOrchestra\OpenApiDocBundle\Attribute\Property; + +class UserView +{ + public Uuid $id; + public string $name; + + #[Property(required: true, attr: ['example' => 'user@example.com'])] + public ?string $email = null; // nullable but explicitly required + + #[Property(attr: ['minLength' => 3, 'maxLength' => 50])] + public string $username; +} +``` + +### 7. Iterable views (lists) + +Use `#[Type]` from `chamber-orchestra/view-bundle` to specify the item type: + +```php +use ChamberOrchestra\ViewBundle\Attribute\Type; +use ChamberOrchestra\ViewBundle\View\IterableView; + +class UserListView extends IterableView +{ + #[Type(UserView::class)] + protected array $entries = []; +} +``` + +Generates: + +```yaml +UserListView: + type: array + items: + $ref: '#/components/schemas/UserView' +``` + +## Generating documentation + +```bash +php bin/console openapi-doc:generate +``` + +Options: + +``` +--src Source directory to scan (default: /src) +--output Output file path (default: /doc.yaml) +--proto Proto YAML file path (default: /proto.yaml) +--title API title (default: "API Documentation") +--doc-version API version string (default: "1.0.0") +``` + +Example: + +```bash +php bin/console openapi-doc:generate \ + --src src/Api \ + --output public/openapi.yaml \ + --proto proto.yaml \ + --title "My API" \ + --doc-version "2.1.0" +``` + +## Type mapping reference + +### PHP types → OpenAPI + +| PHP type | OpenAPI | +|---|---| +| `string` | `type: string` | +| `int` | `type: integer` | +| `float` | `type: number` | +| `bool` | `type: boolean` | +| `array` / `iterable` | `type: array` | +| `BackedEnum` | `type: string\|integer` + `enum: [...]` | +| `Uuid` / `Ulid` | `type: string, format: uuid` | +| `DateTime` / `DateTimeImmutable` | `type: string, format: date-time` | +| Custom class | `$ref: '#/components/schemas/ClassName'` | + +### Form field types → OpenAPI + +| Symfony type | OpenAPI | +|---|---| +| `TextType` | `type: string` | +| `IntegerType` | `type: integer` | +| `NumberType` | `type: number` | +| `CheckboxType` | `type: boolean` | +| `EmailType` | `type: string, format: email` | +| `UrlType` | `type: string, format: uri` | +| `DateType` | `type: string, format: date` | +| `DateTimeType` | `type: string, format: date-time` | +| `ChoiceType` | `type: string` + `enum: [...]` | +| `ChoiceType(multiple: true)` | `type: array, items: { enum: [...] }` | +| `CollectionType` | `type: array, items: { ... }` | +| `RepeatedType` | `type: object` with sub-properties | +| Custom `FormTypeInterface` | `$ref: '#/components/schemas/FormName'` | + +## Architecture + +``` +Action class + └─ Locator scans src/ for #[Operation] + #[Route] + └─ OperationDescriber + └─ SecurityParser #[IsGranted] → security placeholder + └─ RouteParser #[Route] → path / method / operationId + └─ OperationParser #[Operation] → description / request / responses + └─ ResponseParser __invoke return type → ComponentDescriber + └─ ComponentDescriber (lazy, per class) + └─ FormParser FormTypeInterface → schema from form fields + constraints + └─ ViewParser ViewInterface → schema from public properties + └─ ObjectParser plain DTO → schema from public properties + └─ DocumentBuilder + └─ merge proto.yaml + └─ resolve 'default' security → first securityScheme + └─ emit OpenAPI 3.0.1 YAML +``` + +## Running tests + +```bash +composer test # all tests +composer test:unit # unit tests only +composer test:integration # integration tests only +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aaf6e43 --- /dev/null +++ b/composer.json @@ -0,0 +1,75 @@ +{ + "name": "chamber-orchestra/openapi-doc-bundle", + "type": "symfony-bundle", + "description": "Symfony bundle that auto-generates OpenAPI 3.0.1 documentation for Action-Domain-Responder (ADR) pattern applications by scanning #[Operation] and #[Route] attributes.", + "keywords": [ + "symfony", + "bundle", + "openapi", + "swagger", + "api", + "documentation", + "adr", + "action-domain-responder", + "rest", + "generator", + "attributes", + "php8.5" + ], + "license": "MIT", + "authors": [ + { + "name": "Andrew Lukin", + "email": "lukin.andrej@gmail.com", + "homepage": "https://github.com/wtorsi", + "role": "Developer" + }, + { + "name": "Girchenko Mikhail", + "email": "girchenkomikhail@gmail.com", + "homepage": "https://github.com/baldrys-ed", + "role": "Developer" + } + ], + "require": { + "php": "^8.5", + "ext-json": "*", + "chamber-orchestra/view-bundle": "^8.0", + "symfony/config": "^8.0", + "symfony/console": "^8.0", + "symfony/dependency-injection": "^8.0", + "symfony/form": "^8.0", + "symfony/http-kernel": "^8.0", + "symfony/routing": "^8.0", + "symfony/security-http": "^8.0", + "symfony/validator": "^8.0", + "symfony/yaml": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/uid": "^8.0" + }, + "suggest": { + "symfony/uid": "Required to map Uuid/Ulid properties to { type: string, format: uuid } in generated schemas." + }, + "autoload": { + "psr-4": { + "ChamberOrchestra\\OpenApiDocBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ChamberOrchestra\\OpenApiDocBundle\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test:unit": "vendor/bin/phpunit --testsuite Unit", + "test:integration": "vendor/bin/phpunit --testsuite Integrational" + }, + "config": { + "allow-plugins": { + "symfony/runtime": true + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..583785b --- /dev/null +++ b/composer.lock @@ -0,0 +1,5730 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "becb2fc88cb926e94eb90cda4069cca3", + "packages": [ + { + "name": "chamber-orchestra/view-bundle", + "version": "v8.0.19", + "source": { + "type": "git", + "url": "https://github.com/chamber-orchestra/view-bundle.git", + "reference": "19b089f9ac0efdf8256b68c76fe23f7f9393b4b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chamber-orchestra/view-bundle/zipball/19b089f9ac0efdf8256b68c76fe23f7f9393b4b7", + "reference": "19b089f9ac0efdf8256b68c76fe23f7f9393b4b7", + "shasum": "" + }, + "require": { + "doctrine/common": "^3.5", + "php": "^8.5", + "symfony/config": "8.0.*", + "symfony/dependency-injection": "8.0.*", + "symfony/framework-bundle": "^8.0", + "symfony/http-kernel": "8.0.*", + "symfony/property-access": "8.0.*", + "symfony/runtime": "^8.0", + "symfony/serializer": "8.0.*" + }, + "conflict": { + "symfony/symfony": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.94", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.0", + "symfony/test-pack": "^1.2" + }, + "type": "symfony-bundle", + "extra": { + "symfony": { + "require": "8.0.*", + "allow-contrib": false + } + }, + "autoload": { + "psr-4": { + "ChamberOrchestra\\ViewBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrew Lukin", + "email": "lukin.andrej@gmail.com", + "homepage": "https://github.com/wtorsi", + "role": "Developer" + } + ], + "description": "Symfony bundle providing a typed, reusable view layer for building JSON API responses with automatic property binding and cache-warmed serialization", + "homepage": "https://github.com/chamber-orchestra/view-bundle", + "keywords": [ + "JSON-API", + "api-response", + "json", + "property-binding", + "rest-api", + "serialization", + "symfony", + "symfony-bundle", + "view", + "view-model" + ], + "support": { + "issues": "https://github.com/chamber-orchestra/view-bundle/issues", + "source": "https://github.com/chamber-orchestra/view-bundle" + }, + "time": "2026-02-16T18:21:58+00:00" + }, + { + "name": "doctrine/common", + "version": "3.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/common.git", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0", + "doctrine/collections": "^1", + "phpstan/phpstan": "^1.4.1", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5.20 || ^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/phpunit-bridge": "^6.1", + "vimeo/psalm": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "PHP Doctrine Common project is a library that provides additional functionality that other Doctrine projects depend on such as better reflection support, proxies and much more.", + "homepage": "https://www.doctrine-project.org/projects/common.html", + "keywords": [ + "common", + "doctrine", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/common/issues", + "source": "https://github.com/doctrine/common/tree/3.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcommon", + "type": "tidelift" + } + ], + "time": "2025-01-01T22:12:03+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src/Persistence" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2025-10-16T20:13:18+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/cache", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "92e9960386c7e01f58198038c199d522959a843c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/92e9960386c7e01f58198038c199d522959a843c", + "reference": "92e9960386c7e01f58198038c199d522959a843c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "doctrine/dbal": "<4.3", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-13T15:25:07+00:00" + }, + { + "name": "symfony/config", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-23T11:07:10+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:55+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-01T09:13:36+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + }, + { + "name": "symfony/form", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", + "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/options-resolver": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "^1.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/intl": "<7.4", + "symfony/translation-contracts": "<2.5", + "symfony/validator": "<7.4" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Form\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-23T11:07:10+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/e2f9469e7a802dd7c0d193792afc494d68177c54", + "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.4", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<7.4", + "symfony/form": "<7.4", + "symfony/json-streamer": "<7.4", + "symfony/messenger": "<7.4", + "symfony/security-csrf": "<7.4", + "symfony/serializer": "<7.4", + "symfony/translation": "<7.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/json-streamer": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/notifier": "^7.4|^8.0", + "symfony/object-mapper": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/scheduler": "^7.4|^8.0", + "symfony/security-bundle": "^7.4|^8.0", + "symfony/semaphore": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/type-info": "^7.4.1|^8.0.1", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:06:10+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", + "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20c1c5e41fc53928dbb670088f544f2d460d497d", + "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-28T10:46:31+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:55:31+00:00" + }, + { + "name": "symfony/password-hasher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ca6af4e20357d58d50c818d676cf2e2dd5e53b02", + "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T23:07:29+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-20T22:24:30+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T09:27:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "9d987224b54758240e80a062c5e414431bbf84de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/9d987224b54758240e80a062c5e414431bbf84de", + "reference": "9d987224b54758240e80a062c5e414431bbf84de", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.4|^8.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/4a2bc08d1c35307239329f434d45c2bfe8241fa9", + "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T12:37:40+00:00" + }, + { + "name": "symfony/runtime", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "73b34037b23db051048ba2873031ddb89be9f19d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/73b34037b23db051048ba2873031ddb89be9f19d", + "reference": "73b34037b23db051048ba2873031ddb89be9f19d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.4" + }, + "conflict": { + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-05T14:08:45+00:00" + }, + { + "name": "symfony/security-core", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "c62565de41a136535ffa79a4db0373a7173b4d02" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/c62565de41a136535ffa79a4db0373a7173b4d02", + "reference": "c62565de41a136535ffa79a4db0373a7173b4d02", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/ldap": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Core\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-23T11:07:10+00:00" + }, + { + "name": "symfony/security-http", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "02f37c050db6e997052916194086d1a0a8790b8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/02f37c050db6e997052916194086d1a0a8790b8f", + "reference": "02f37c050db6e997052916194086d1a0a8790b8f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-23T11:07:10+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "867a38a1927d23a503f7248aa182032c6ea42702" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/867a38a1927d23a503f7248aa182032c6ea42702", + "reference": "867a38a1927d23a503f7248aa182032c6ea42702", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-info": "<7.3" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:06:43+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "758b372d6882506821ed666032e43020c4f57194" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T12:37:40+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-09T12:15:10+00:00" + }, + { + "name": "symfony/validator", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/ba171e89ee2d01c24c1d8201d59ec595ef4adba1", + "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/doctrine-bridge": "<7.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:06:10+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", + "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T23:07:29+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T18:53:00+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-04T18:17:06+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/8b81bd3700f5c1913c22a3266a647aa1bb974435", + "reference": "8b81bd3700f5c1913c22a3266a647aa1bb974435", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:40:55+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.5", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/php-cs-fixer.dist.php b/php-cs-fixer.dist.php new file mode 100644 index 0000000..10928e2 --- /dev/null +++ b/php-cs-fixer.dist.php @@ -0,0 +1,35 @@ +in(__DIR__) + ->exclude('var') + ->exclude('vendor') +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS' => true, + '@Symfony' => true, + 'declare_strict_types' => true, + 'strict_param' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'trailing_comma_in_multiline' => true, + 'single_quote' => true, + 'global_namespace_import' => [ + 'import_classes' => false, + 'import_constants' => false, + 'import_functions' => false, + ], + 'native_function_invocation' => [ + 'include' => ['@all'], + 'scope' => 'all', + 'strict' => true, + ], + ]) + ->setFinder($finder) + ->setRiskyAllowed(true) +; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a2409ed --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + tests/Unit + + + tests/Integrational + + + + + + src + + + diff --git a/src/Attribute/Model.php b/src/Attribute/Model.php new file mode 100644 index 0000000..f334625 --- /dev/null +++ b/src/Attribute/Model.php @@ -0,0 +1,15 @@ +buildPaths($protoData); + $components = $this->buildComponents($protoData); + + return [ + 'openapi' => '3.0.1', + 'info' => ['version' => $version, 'title' => $title], + 'paths' => $paths, + 'components' => $components, + ]; + } + + private function buildPaths(array $protoData): array + { + $paths = $this->operationRegistry->getAll(); + $protoSecuritySchemes = $protoData['components']['securitySchemes'] ?? []; + $firstSecurity = !empty($protoSecuritySchemes) ? array_key_first($protoSecuritySchemes) : null; + + foreach ($paths as &$methods) { + foreach ($methods as &$operation) { + // Do NOT use ?? here: it creates a temporary copy, breaking the reference chain. + if (empty($operation['security'])) { + continue; + } + foreach ($operation['security'] as &$security) { + if (isset($security['default'])) { + if (null !== $firstSecurity) { + // Replace placeholder with the first defined scheme + $security[$firstSecurity] = $security['default']; + } + unset($security['default']); + } + } + // Remove security entries that became empty after stripping 'default' + $operation['security'] = array_values(array_filter($operation['security'])); + if (empty($operation['security'])) { + unset($operation['security']); + } + } + } + + return $paths; + } + + private function buildComponents(array $protoData): array + { + $excludedIds = $this->operationRegistry->getExcludedComponentIds(); + $components = $this->componentRegistry->getAll($excludedIds); + + $securitySchemes = $protoData['components']['securitySchemes'] ?? []; + if (!empty($securitySchemes)) { + $components['securitySchemes'] = $securitySchemes; + } + + $schemas = $protoData['components']['schemas'] ?? []; + $components['schemas'] = array_merge($components['schemas'] ?? [], $schemas); + + $responses = $protoData['components']['responses'] ?? []; + if (!empty($responses)) { + $components['responses'] = $responses; + } + + return $components; + } +} diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php new file mode 100644 index 0000000..637aab6 --- /dev/null +++ b/src/Command/AbstractCommand.php @@ -0,0 +1,20 @@ +io = new SymfonyStyle($input, $output); + } +} diff --git a/src/Command/OpenApiDocGenerator.php b/src/Command/OpenApiDocGenerator.php new file mode 100644 index 0000000..e50439d --- /dev/null +++ b/src/Command/OpenApiDocGenerator.php @@ -0,0 +1,104 @@ +addOption('src', null, InputOption::VALUE_OPTIONAL, 'Source code directory, default /src.') + ->addOption('output', null, InputOption::VALUE_OPTIONAL, 'Output yaml file, default /doc.yaml.') + ->addOption('proto', null, InputOption::VALUE_OPTIONAL, 'Proto file which will be included in output, default /proto.yaml.') + ->addOption('title', null, InputOption::VALUE_OPTIONAL) + ->addOption('doc-version', null, InputOption::VALUE_OPTIONAL); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $title = $input->getOption('title') ?: 'API Documentation'; + $version = $input->getOption('doc-version') ?: '1.0.0'; + $src = $input->getOption('src') ?: $this->kernel->getProjectDir().'/src'; + $outputFile = $input->getOption('output') ?: $this->kernel->getProjectDir().'/doc.yaml'; + + if (!is_dir($src)) { + $this->io->error('Passed src is invalid'); + + return Command::FAILURE; + } + $proto = $input->getOption('proto') ?: $this->kernel->getProjectDir().'/proto.yaml'; + + $classes = $this->locator->locate($src); + + foreach ($classes as $class) { + try { + $this->operationDescriber->describe($class); + } catch (DescriberException $e) { + $this->io->warning(sprintf('Skipping class %s: %s', $class, $e->getMessage())); + } + } + + $protoData = []; + if (file_exists($proto)) { + try { + $protoData = Yaml::parseFile($proto); + } catch (Exception $exception) { + $this->io->error('Error with proto file parsing: '.$exception->getMessage()); + + return Command::FAILURE; + } + } + + $data = $this->documentBuilder->build($protoData, $version, $title); + + $this->write($data, $outputFile); + + $this->io->success('done'); + + return 0; + } + + private function write(array $data, string $file): void + { + $yaml = Yaml::dump($data, 10, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + + // PHP array key coercion turns '200' into int 200, so the YAML dumper + // outputs bare integer keys. OpenAPI 3.x requires string keys for HTTP + // status codes. Quote every 3-digit numeric key that appears alone on a line. + $yaml = preg_replace('/^( +)([1-5]\d{2}):$/m', '$1\'$2\':', $yaml); + + if (false === file_put_contents($file, $yaml)) { + throw new \RuntimeException(sprintf( + 'Failed to write documentation to "%s". Check that the directory exists and is writable.', + $file + )); + } + } +} diff --git a/src/DependencyInjection/OpenApiDocExtension.php b/src/DependencyInjection/OpenApiDocExtension.php new file mode 100644 index 0000000..6f503d8 --- /dev/null +++ b/src/DependencyInjection/OpenApiDocExtension.php @@ -0,0 +1,18 @@ +load('services.yaml'); + } +} diff --git a/src/Describer/AbstractDescriber.php b/src/Describer/AbstractDescriber.php new file mode 100644 index 0000000..06d7b27 --- /dev/null +++ b/src/Describer/AbstractDescriber.php @@ -0,0 +1,44 @@ +getItemsToParse($class); + + $model = new ($this->getModel())(); + + /* @var ParserInterface $parser */ + foreach ($items as $item) { + foreach ($this->parsers as $parser) { + if ($parser->supports($item)) { + $model = $parser->parse($model, $item); + break; + } + } + } + + if (null === $model->id){ + throw new DescriberException('No parsers was found for $class '.$class); + } + + return $this->registry->register($model); + } +} diff --git a/src/Describer/ComponentDescriber.php b/src/Describer/ComponentDescriber.php new file mode 100644 index 0000000..5bc445b --- /dev/null +++ b/src/Describer/ComponentDescriber.php @@ -0,0 +1,42 @@ + Classes currently being described (cycle guard) */ + private array $inProgress = []; + + public function describe(string $class): Model + { + if (isset($this->inProgress[$class])) { + return $this->inProgress[$class]; + } + + $stub = new Component(); + $stub->id = (new ReflectionClass($class))->getShortName(); + $this->inProgress[$class] = $stub; + + try { + return parent::describe($class); + } finally { + unset($this->inProgress[$class]); + } + } + + protected function getItemsToParse(string $class): iterable + { + return [new ReflectionClass($class)]; + } + + protected function getModel(): string + { + return Component::class; + } +} diff --git a/src/Describer/DescriberInterface.php b/src/Describer/DescriberInterface.php new file mode 100644 index 0000000..27d9398 --- /dev/null +++ b/src/Describer/DescriberInterface.php @@ -0,0 +1,17 @@ +getMethod('__invoke'); + $returnType = $invoke->getReturnType(); + + $types = []; + if ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $rc = new ReflectionClass($type->getName()); + if (!$rc->isInterface() && !$rc->isAbstract()) { + $types[] = $rc; + } + } + } + } elseif ($returnType instanceof ReflectionNamedType && !$returnType->isBuiltin()) { + $rc = new ReflectionClass($returnType->getName()); + if (!$rc->isInterface() && !$rc->isAbstract()) { + $types[] = $rc; + } + } + + return array_merge($reflection->getAttributes(), $types); + } + + protected function getModel(): string + { + return Operation::class; + } +} diff --git a/src/Exception/DescriberException.php b/src/Exception/DescriberException.php new file mode 100644 index 0000000..39881a6 --- /dev/null +++ b/src/Exception/DescriberException.php @@ -0,0 +1,9 @@ +getClasses($srcPath); + } + + private function getClasses(string $srcPath): iterable + { + $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($srcPath)); + + $classes = []; + foreach ($rii as $file) { + if ($file->isDir()) { + continue; + } + + if ($file->getExtension() !== 'php') { + continue; + } + + // Pre-filter: only load classes whose source mentions both attributes. + // This avoids triggering class-loading (and potential uncatchable fatal + // compile errors) on files that cannot possibly match. + $content = file_get_contents($file->getPathname()); + if (!str_contains($content, '#[Operation') || !str_contains($content, '#[Route')) { + continue; + } + + if ($class = $this->classParser->extractClass($file->getPathname())) { + try { + $reflectionClass = new ReflectionClass($class); + } catch (\Throwable $e) { + continue; + } + if ($this->containsAttribute($reflectionClass, Operation::class) && $this->containsAttribute($reflectionClass, Route::class)) { + $classes[] = $class; + } + } + } + + return $classes; + } + + private function containsAttribute(ReflectionClass $reflectionClass, string $attributeName): bool + { + $attributes = $reflectionClass->getAttributes(); + + foreach ($attributes as $attribute) { + if ($attribute->getName() === $attributeName) { + return true; + } + } + + return false; + } +} diff --git a/src/Model/Component.php b/src/Model/Component.php new file mode 100644 index 0000000..c24f723 --- /dev/null +++ b/src/Model/Component.php @@ -0,0 +1,21 @@ +name = $name; + $property->type = $type; + $property->format = $format; + + return $property; + } +} diff --git a/src/OpenApiDocBundle.php b/src/OpenApiDocBundle.php new file mode 100644 index 0000000..a134b07 --- /dev/null +++ b/src/OpenApiDocBundle.php @@ -0,0 +1,11 @@ +getInterfaceNames()); + } + + public function parse(Model $model, object $item): Model + { + /* @var ReflectionClass $item */ + /* @var Component $model */ + $form = $this->formFactory->create($item->getName()); + + $model->id = $item->getShortName(); + $required = []; + foreach ($form as $name => $child) { + $config = $child->getConfig(); + + if ($config->getRequired()) { + $required[] = $name; + } + + $property = Property::factory($name); + $model->properties[] = $property; + $this->findFormType($property, $config); + } + + $model->required = $required; + + return $model; + } + + private function findFormType(Property $property, $config): void + { + /* @var Component $childComponent */ + $type = $config->getType(); + + if (!$builtinFormType = AbstractFormTypeHandler::getBuiltinFormType($type)) { + $childComponent = $this->describer->describe(get_class($type->getInnerType())); + $property->ref = $childComponent; + $childComponent->parent = $property; + + return; + } + + $blockPrefix = $builtinFormType->getBlockPrefix(); + + /* @var FormTypeHandlerInterface $handler */ + foreach ($this->handlers as $handler) { + if ($handler->supports($blockPrefix)) { + $handler->handle($property, $config); + return; + } + } + } + +} diff --git a/src/Parser/FormTypeHandler/AbstractFormTypeHandler.php b/src/Parser/FormTypeHandler/AbstractFormTypeHandler.php new file mode 100644 index 0000000..4e4bf7b --- /dev/null +++ b/src/Parser/FormTypeHandler/AbstractFormTypeHandler.php @@ -0,0 +1,104 @@ +getInnerType()); + + if (FormType::class === $class) { + return null; + } + + if ('entity' === $type->getBlockPrefix() || 'document' === $type->getBlockPrefix()) { + return $type; + } + + if (str_starts_with($class, 'Symfony\Component\Form\Extension\Core\Type\\')) { + return $type; + } + } while ($type = $type->getParent()); + + return null; + } + + /** + * Dispatches a sub-property to the appropriate handler or describes it as a component $ref. + * Shared by CollectionTypeHandler and RepeatedTypeHandler. + */ + protected function dispatchSubProperty( + Property $property, + FormConfigInterface $config, + iterable $handlers, + DescriberInterface $describer, + ): void { + $type = $config->getType(); + + if (!$builtinFormType = self::getBuiltinFormType($type)) { + $child = $describer->describe(get_class($type->getInnerType())); + $property->ref = $child; + $child->parent = $property; + + return; + } + + $blockPrefix = $builtinFormType->getBlockPrefix(); + + foreach ($handlers as $handler) { + if ($handler->supports($blockPrefix)) { + $handler->handle($property, $config); + + return; + } + } + } +} diff --git a/src/Parser/FormTypeHandler/CheckboxTypeHandler.php b/src/Parser/FormTypeHandler/CheckboxTypeHandler.php new file mode 100644 index 0000000..4cea251 --- /dev/null +++ b/src/Parser/FormTypeHandler/CheckboxTypeHandler.php @@ -0,0 +1,21 @@ +type = 'boolean'; + } +} diff --git a/src/Parser/FormTypeHandler/ChoiceTypeHandler.php b/src/Parser/FormTypeHandler/ChoiceTypeHandler.php new file mode 100644 index 0000000..2c05eae --- /dev/null +++ b/src/Parser/FormTypeHandler/ChoiceTypeHandler.php @@ -0,0 +1,58 @@ +getOption('multiple'); + $property->type = $multiple ? 'array' : 'string'; + + if ($multiple) { + $constraints = $config->getOption('constraints') ?? []; + if ($constraint = $this->findConstraint($constraints, Count::class)) { + if (null !== $constraint->min) { + $property->attributes['minItems'] = $constraint->min; + } + if (null !== $constraint->max) { + $property->attributes['maxItems'] = $constraint->max; + } + } + } + + if (($choices = $config->getOption('choices')) && is_array($choices) && count($choices)) { + $enums = array_values(array_map( + static fn($c) => $c instanceof \BackedEnum ? $c->value : $c, + $choices, + )); + if ($this->isNumbersArray($enums)) { + $type = 'number'; + } elseif ($this->isBooleansArray($enums)) { + $type = 'boolean'; + } else { + $type = 'string'; + } + + if ($multiple) { + $subProperty = Property::factory('items', $type); + $subProperty->enum = $enums; + $property->items = $subProperty; + } else { + $property->type = $type; + $property->enum = $enums; + } + } + } +} diff --git a/src/Parser/FormTypeHandler/CollectionTypeHandler.php b/src/Parser/FormTypeHandler/CollectionTypeHandler.php new file mode 100644 index 0000000..78046c4 --- /dev/null +++ b/src/Parser/FormTypeHandler/CollectionTypeHandler.php @@ -0,0 +1,49 @@ +getOption('entry_type'); + $subOptions = $config->getOption('entry_options'); + $subForm = $this->formFactory->create($subType, null, $subOptions); + + $property->type = 'array'; + $subProperty = Property::factory('items', $subType); + $property->items = $subProperty; + + $constraints = $config->getOption('constraints'); + if ($constraint = $this->findConstraint($constraints, Count::class)) { + if (null !== $constraint->min) { + $property->attributes['minItems'] = $constraint->min; + } + if (null !== $constraint->max) { + $property->attributes['maxItems'] = $constraint->max; + } + } + + $this->dispatchSubProperty($subProperty, $subForm->getConfig(), $this->handlers, $this->describer); + } +} diff --git a/src/Parser/FormTypeHandler/DateTimeTypeHandler.php b/src/Parser/FormTypeHandler/DateTimeTypeHandler.php new file mode 100644 index 0000000..fef8968 --- /dev/null +++ b/src/Parser/FormTypeHandler/DateTimeTypeHandler.php @@ -0,0 +1,22 @@ +type = 'string'; + $property->format = 'date-time'; + } +} diff --git a/src/Parser/FormTypeHandler/DateTypeHandler.php b/src/Parser/FormTypeHandler/DateTypeHandler.php new file mode 100644 index 0000000..4187cf3 --- /dev/null +++ b/src/Parser/FormTypeHandler/DateTypeHandler.php @@ -0,0 +1,22 @@ +type = 'string'; + $property->format = 'date'; + } +} diff --git a/src/Parser/FormTypeHandler/EntityTypeHandler.php b/src/Parser/FormTypeHandler/EntityTypeHandler.php new file mode 100644 index 0000000..c655906 --- /dev/null +++ b/src/Parser/FormTypeHandler/EntityTypeHandler.php @@ -0,0 +1,31 @@ +getOption('class'); + + if ($config->getOption('multiple')) { + $property->format = sprintf('[%s id]', $entityClass); + $property->type = 'array'; + $subProperty = Property::factory('items', 'string'); + $property->items = $subProperty; + } else { + $property->type = 'string'; + $property->format = sprintf('%s id', $entityClass); + } + } +} diff --git a/src/Parser/FormTypeHandler/FileTypeHandler.php b/src/Parser/FormTypeHandler/FileTypeHandler.php new file mode 100644 index 0000000..94c5413 --- /dev/null +++ b/src/Parser/FormTypeHandler/FileTypeHandler.php @@ -0,0 +1,21 @@ +type = 'string'; + } +} diff --git a/src/Parser/FormTypeHandler/FormTypeHandlerInterface.php b/src/Parser/FormTypeHandler/FormTypeHandlerInterface.php new file mode 100644 index 0000000..86a1daa --- /dev/null +++ b/src/Parser/FormTypeHandler/FormTypeHandlerInterface.php @@ -0,0 +1,16 @@ +getType()->getBlockPrefix(); + $property->type = 'number' === $blockPrefix ? 'number' : 'integer'; + $constraints = $config->getOption('constraints'); + + // Positive/PositiveOrZero are checked before GreaterThan/GreaterThanOrEqual + // because Positive extends GreaterThan (value=0) and would otherwise overwrite. + if ($this->findConstraint($constraints, PositiveOrZero::class)) { + $property->attributes['minimum'] = 0; + } elseif ($this->findConstraint($constraints, Positive::class)) { + $property->attributes['minimum'] = 1; + } elseif ($constraint = $this->findConstraint($constraints, GreaterThanOrEqual::class)) { + $property->attributes['minimum'] = $constraint->value; + } elseif ($constraint = $this->findConstraint($constraints, GreaterThan::class)) { + $property->attributes['minimum'] = $constraint->value; + } + + if ($constraint = $this->findConstraint($constraints, LessThanOrEqual::class)) { + $property->attributes['maximum'] = $constraint->value; + } elseif ($constraint = $this->findConstraint($constraints, LessThan::class)) { + $property->attributes['maximum'] = $constraint->value; + } + + // Range is applied only when no more specific constraint already set the boundary. + if ($constraint = $this->findConstraint($constraints, Range::class)) { + if (null !== $constraint->min && !isset($property->attributes['minimum'])) { + $property->attributes['minimum'] = $constraint->min; + } + if (null !== $constraint->max && !isset($property->attributes['maximum'])) { + $property->attributes['maximum'] = $constraint->max; + } + } + } +} diff --git a/src/Parser/FormTypeHandler/PasswordTypeHandler.php b/src/Parser/FormTypeHandler/PasswordTypeHandler.php new file mode 100644 index 0000000..750271d --- /dev/null +++ b/src/Parser/FormTypeHandler/PasswordTypeHandler.php @@ -0,0 +1,22 @@ +type = 'string'; + $property->format = 'password'; + } +} diff --git a/src/Parser/FormTypeHandler/RepeatedTypeHandler.php b/src/Parser/FormTypeHandler/RepeatedTypeHandler.php new file mode 100644 index 0000000..95beb3d --- /dev/null +++ b/src/Parser/FormTypeHandler/RepeatedTypeHandler.php @@ -0,0 +1,48 @@ +type = 'object'; + $property->requiredProperties = [$config->getOption('first_name'), $config->getOption('second_name')]; + + $subType = $config->getOption('type'); + + foreach (['first', 'second'] as $subField) { + $subName = $config->getOption($subField.'_name'); + $subForm = $this->formFactory->create( + $subType, + null, + array_merge( + $config->getOption('options'), + $config->getOption($subField.'_options') + ) + ); + $subProperty = Property::factory($subName); + $property->properties[] = $subProperty; + $this->dispatchSubProperty($subProperty, $subForm->getConfig(), $this->handlers, $this->describer); + } + } +} diff --git a/src/Parser/FormTypeHandler/TextTypeHandler.php b/src/Parser/FormTypeHandler/TextTypeHandler.php new file mode 100644 index 0000000..9117a01 --- /dev/null +++ b/src/Parser/FormTypeHandler/TextTypeHandler.php @@ -0,0 +1,47 @@ +type = 'string'; + $constraints = $config->getOption('constraints'); + + if ($constraint = $this->findConstraint($constraints, Length::class)) { + if (null !== $constraint->min) { + $property->attributes['minLength'] = $constraint->min; + } + if (null !== $constraint->max) { + $property->attributes['maxLength'] = $constraint->max; + } + } + + if ($this->findConstraint($constraints, Email::class)) { + $property->format = 'email'; + } + + if ($this->findConstraint($constraints, Url::class)) { + $property->format = 'uri'; + } + + if ($constraint = $this->findConstraint($constraints, Regex::class)) { + $property->attributes['pattern'] = $constraint->pattern; + } + } +} diff --git a/src/Parser/ObjectParser.php b/src/Parser/ObjectParser.php new file mode 100644 index 0000000..9f80df5 --- /dev/null +++ b/src/Parser/ObjectParser.php @@ -0,0 +1,90 @@ +getInterfaceNames()) + && !in_array(FormTypeInterface::class, $item->getInterfaceNames()) + && $item->getName() !== ViewInterface::class; + } + + public function parse(Model $model, object $reflection): Model + { + /* @var Component $model */ + /* @var ReflectionClass $reflection */ + $model->id = $reflection->getShortName(); + $parameters = []; + $required = []; + + foreach ($reflection->getProperties() as $property) { + $reflectionType = $property->getType(); + $typeName = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + + if (null === $typeName) { + $parameter = Property::factory($property->getName(), 'string'); + } else { + $openApiType = $this->typeConverter->toOpenApiType($typeName); + if (null === $openApiType) { + if ($openApiProperty = $this->typeConverter->toOpenApiProperty($typeName)) { + $parameter = Property::factory($property->getName(), $openApiProperty['type']); + if (isset($openApiProperty['format'])) { + $parameter->format = $openApiProperty['format']; + } + if (!empty($openApiProperty['enum'])) { + $parameter->enum = $openApiProperty['enum']; + } + } else { + try { + $child = $this->describer->describe($typeName); + $parameter = Property::factory($property->getName(), 'object'); + $parameter->ref = $child; + } catch (DescriberException $e) { + $parameter = Property::factory($property->getName(), 'string'); + } + } + } else { + $parameter = Property::factory($property->getName(), $openApiType); + } + } + + $parameter = $this->parser->parse($parameter, $property); + $parameters[] = $parameter; + + $isRequired = ($reflectionType instanceof ReflectionNamedType && !$reflectionType->allowsNull()) + || $parameter->required === true; + if ($isRequired) { + $required[] = $property->getName(); + } + } + + $model->properties = $parameters; + $model->required = $required; + + return $model; + } +} diff --git a/src/Parser/OperationParser.php b/src/Parser/OperationParser.php new file mode 100644 index 0000000..9640fff --- /dev/null +++ b/src/Parser/OperationParser.php @@ -0,0 +1,67 @@ +getName() === Operation::class; + } + + public function parse(Model $model, object $attribute): Model + { + /* @var OperationModel $model */ + /* @var ReflectionAttribute $attribute */ + $instance = $attribute->newInstance(); + $model->description = $instance->description; + + $request = $instance->request; + $requestModel = null; + if (null !== $request) { + if (class_exists($request)) { + $requestModel = $this->componentDescriber->describe($request); + } else { + $requestModel = new Component(); + $requestModel->id = $request; + } + } + + if (!empty($security = $instance->security)) { + $modelSecurity = $model->security; + if (isset($modelSecurity['default'])) { + foreach ($security as $key => $value) { + $security[$key] = array_merge($value, $modelSecurity['default']); + } + } + $model->security = $security; + } + + foreach ($instance->responses as $status => $response) { + if (is_string($response) && class_exists($response)) { + $component = $this->componentDescriber->describe($response); + $model->responses[$status] = $component; + } else { + $model->responses[$status] = $response; + } + } + $model->request = $requestModel; + + return $model; + } +} diff --git a/src/Parser/OperationParserInterface.php b/src/Parser/OperationParserInterface.php new file mode 100644 index 0000000..f435676 --- /dev/null +++ b/src/Parser/OperationParserInterface.php @@ -0,0 +1,9 @@ +getAttributes(); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + if ($instance instanceof \ChamberOrchestra\OpenApiDocBundle\Attribute\Property) { + $property->required = $instance->required; + $property->attributes += $instance->attr; + } + } + + return $property; + } +} \ No newline at end of file diff --git a/src/Parser/ResponseParser.php b/src/Parser/ResponseParser.php new file mode 100644 index 0000000..31bd73c --- /dev/null +++ b/src/Parser/ResponseParser.php @@ -0,0 +1,31 @@ +componentDescriber->describe($reflection->getName()); + $model->responses[$response->status] = $response; + + return $model; + } +} \ No newline at end of file diff --git a/src/Parser/RouteParser.php b/src/Parser/RouteParser.php new file mode 100644 index 0000000..c5d8345 --- /dev/null +++ b/src/Parser/RouteParser.php @@ -0,0 +1,59 @@ +getName() === Route::class; + } + + public function parse(Model $model, object $attribute): Model + { + /* @var Route $instance */ + /* @var Operation $model */ + $instance = $attribute->newInstance(); + $model->path = $instance->path; + // Use route name if available, otherwise derive from path + first method + $model->id = $instance->name ?? $this->deriveOperationId($instance->path, $instance->methods[0] ?? 'GET'); + + // Extract path template parameters, e.g. {id}, {group} + preg_match_all('/\{(\w+)\}/', $instance->path, $matches); + foreach ($matches[1] as $paramName) { + $model->parameters[] = [ + 'name' => $paramName, + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]; + } + + $methods = $instance->methods; + if (empty($methods)) { + throw new \InvalidArgumentException(sprintf( + 'Route "%s" has no HTTP method defined. An explicit method (GET, POST, etc.) is required.', + $instance->name ?? $instance->path + )); + } + $model->method = $methods[0]; + + return $model; + } + + private function deriveOperationId(string $path, string $method): string + { + $normalized = preg_replace('/[^a-zA-Z0-9]+/', '_', trim($path, '/')); + $normalized = trim($normalized, '_'); + + return strtolower($method) . '_' . ($normalized ?: 'root'); + } +} \ No newline at end of file diff --git a/src/Parser/SecurityParser.php b/src/Parser/SecurityParser.php new file mode 100644 index 0000000..dd02304 --- /dev/null +++ b/src/Parser/SecurityParser.php @@ -0,0 +1,27 @@ +getName() === IsGranted::class; + } + + public function parse(Model $model, object $attribute): Model + { + if (empty($model->security)) { + $model->security['default'] = []; + } + + return $model; + } +} \ No newline at end of file diff --git a/src/Parser/ViewParser.php b/src/Parser/ViewParser.php new file mode 100644 index 0000000..ac7c0eb --- /dev/null +++ b/src/Parser/ViewParser.php @@ -0,0 +1,216 @@ +getInterfaceNames()); + } + + public function parse(Model $model, object $item): Model + { + /* @var Component $model */ + /* @var ReflectionClass $item */ + + $status = Response::HTTP_OK; + $headers = ['Content-Type' => 'application/json']; + + if (in_array(ResponseViewInterface::class, $item->getInterfaceNames())) { + $instance = $item->newInstance(); + $status = $item->getMethod('getStatus')->invoke($instance); + $headers = $item->getMethod('getHeaders')->invoke($instance); + } + + $model->id = $item->getShortName(); + + if (null === $model->parent) { + $model->status = $status; + $model->headers = $headers; + } + + // IterableView itself or subclasses serialize as a plain JSON array + if ($item->getName() === IterableView::class || $item->isSubclassOf(IterableView::class)) { + $model->type = 'array'; + $itemClass = $this->resolveIterableItemType($item); + if (null !== $itemClass) { + try { + $child = $this->describer->describe($itemClass); + $child->parent = $model; + $itemsProperty = Property::factory('items'); + $itemsProperty->ref = $child; + $model->items = $itemsProperty; + } catch (\Exception $e) { + // item schema unresolvable — leave as plain type: array + } + } + + return $model; + } + + $parameters = []; + $required = []; + foreach ($item->getProperties() as $property) { + $parameter = $this->buildProperty($property, $model); + $parameter = $this->parser->parse($parameter, $property); + $parameters[] = $parameter; + + $type = $property->getType(); + $isRequired = ($type instanceof ReflectionNamedType && !$type->allowsNull()) + || $parameter->required === true; + if ($isRequired) { + $required[] = $property->getName(); + } + } + + $model->properties = $parameters; + $model->required = $required; + + return $model; + } + + private function buildProperty(ReflectionProperty $property, Component $model): Property + { + $propertyName = $property->getName(); + $reflectionType = $property->getType(); + + if (!$reflectionType instanceof ReflectionNamedType) { + return Property::factory($propertyName, 'string'); + } + + $phpTypeName = $reflectionType->getName(); + $openApiType = $this->typeConverter->toOpenApiType($phpTypeName); + + // Primitive type (int, string, bool, float, array, iterable) + if (null !== $openApiType) { + $parameter = Property::factory($propertyName, $openApiType); + if ('array' === $openApiType) { + $this->resolveArrayItems($parameter, $property); + } + + return $parameter; + } + + // IterableView property — always renders as array; items resolved from #[Type] + if ($this->isIterableViewType($phpTypeName)) { + $parameter = Property::factory($propertyName, 'array'); + $this->resolveArrayItems($parameter, $property); + + return $parameter; + } + + // BackedEnum or well-known type (Uuid, DateTime) — map to primitive + if ($openApiProperty = $this->typeConverter->toOpenApiProperty($phpTypeName)) { + $parameter = Property::factory($propertyName, $openApiProperty['type']); + if (isset($openApiProperty['format'])) { + $parameter->format = $openApiProperty['format']; + } + if (!empty($openApiProperty['enum'])) { + $parameter->enum = $openApiProperty['enum']; + } + + return $parameter; + } + + // Other class — describe as a component $ref + try { + $child = $this->describer->describe($phpTypeName); + $child->parent = $model; + $parameter = Property::factory($propertyName, 'object'); + $parameter->ref = $child; + } catch (ReflectionException $e) { + $parameter = Property::factory($propertyName, 'string'); + } + + return $parameter; + } + + /** + * Resolves the item type of an IterableView subclass via two mechanisms: + * 1. #[Type(ItemClass::class)] attribute on the $entries property + * 2. Return type of the overridden map() method + */ + private function resolveIterableItemType(ReflectionClass $item): ?string + { + try { + $entriesProperty = $item->getProperty('entries'); + foreach ($entriesProperty->getAttributes() as $attr) { + if ($attr->getName() === Type::class) { + return $attr->newInstance()->class; + } + } + } catch (ReflectionException $e) { + // entries property not accessible + } + + try { + $mapMethod = $item->getMethod('map'); + $returnType = $mapMethod->getReturnType(); + if ($returnType instanceof ReflectionNamedType && !$returnType->isBuiltin()) { + $typeName = $returnType->getName(); + if ($typeName !== ViewInterface::class) { + return $typeName; + } + } + } catch (ReflectionException $e) { + // map() not found + } + + return null; + } + + /** + * Checks for #[Type(ItemClass::class)] on a property and populates $parameter->items + * with a $ref to the item schema. + */ + private function resolveArrayItems(Property $parameter, ReflectionProperty $property): void + { + foreach ($property->getAttributes() as $attr) { + if ($attr->getName() === Type::class) { + $itemClass = $attr->newInstance()->class; + try { + $itemChild = $this->describer->describe($itemClass); + $itemsProperty = Property::factory('items'); + $itemsProperty->ref = $itemChild; + $parameter->items = $itemsProperty; + } catch (\Exception $e) { + // item schema unresolvable + } + break; + } + } + } + + private function isIterableViewType(string $className): bool + { + return $className === IterableView::class + || (class_exists($className) && is_a($className, IterableView::class, true)); + } +} diff --git a/src/Registry/ComponentRegistry.php b/src/Registry/ComponentRegistry.php new file mode 100644 index 0000000..c2491e9 --- /dev/null +++ b/src/Registry/ComponentRegistry.php @@ -0,0 +1,84 @@ +models; + + /* @var Component $component */ + /* @var Property $property */ + foreach ($components as $component) { + if (in_array($component->id, $excludedIds, true)) { + continue; + } + if ('array' === $component->type) { + $componentData = ['type' => 'array']; + if (null !== $component->items) { + $componentData['items'] = $this->propertyToArray($component->items); + } + } else { + $componentData = ['properties' => []]; + $properties = $component->properties; + foreach ($properties as $property) { + $componentData['properties'][$property->name] = $this->propertyToArray($property); + } + if (!empty($required = $component->required)) { + $componentData['required'] = $required; + } + } + $data[$component->id] = $componentData; + } + + return [ + 'schemas' => $data, + ]; + } + + private function propertyToArray(Property $property): array + { + $data = []; + + if (null !== $ref = $property->ref) { + $data['$ref'] = '#/components/schemas/'.$ref->id; + } else { + $data['type'] = $property->type; + } + + if (null !== $format = $property->format) { + $data['format'] = $format; + } + + if (!empty($items = $property->items)) { + $data['items'] = $this->propertyToArray($items); + } elseif (($data['type'] ?? null) === 'array') { + $data['items'] = ['type' => 'object']; + } + + if (!empty($enum = $property->enum)) { + $data['enum'] = $enum; + } + + if (!empty($property->properties)) { + foreach ($property->properties as $subProperty) { + $data['properties'][$subProperty->name] = $this->propertyToArray($subProperty); + } + if (!empty($property->requiredProperties)) { + $data['required'] = $property->requiredProperties; + } + } + + $data += $property->attributes; + + return $data; + } +} diff --git a/src/Registry/OperationRegistry.php b/src/Registry/OperationRegistry.php new file mode 100644 index 0000000..3d790a3 --- /dev/null +++ b/src/Registry/OperationRegistry.php @@ -0,0 +1,118 @@ +excludedComponentIds; + } + + public function getAll(): array + { + $this->excludedComponentIds = []; + $operations = $this->models; + $data = []; + + /* @var Operation $operation */ + /* @var string|Component $response */ + foreach ($operations as $operation) { + if ('' === $operation->path || '' === $operation->method) { + throw new \LogicException(sprintf( + 'Operation "%s" is missing path or method.', + $operation->id ?? '(unknown)', + )); + } + + $pathData = []; + if (null !== $description = $operation->description) { + $pathData['description'] = $description; + } + $pathData['operationId'] = $operation->id; + + if (!empty($operation->parameters)) { + $pathData['parameters'] = $operation->parameters; + } + + $responses = []; + foreach ($operation->responses as $status => $response) { + if ($response instanceof Component) { + // OpenAPI requires status codes to be string keys ("200", not 200) + $httpStatus = (string)($response->status ?? 200); + $content = $response->headers['Content-Type'] ?? 'application/json'; + $responses[$httpStatus]['description'] = $response->id ?? 'Successful response'; + $responses[$httpStatus]['content'][$content]['schema']['$ref'] = + '#/components/schemas/'.$response->id; + } else { + $responses[(string)$status]['$ref'] = '#/components/responses/'.$response; + } + } + + if (null !== $operation->request) { + if (in_array(strtoupper($operation->method), ['GET', 'DELETE', 'HEAD'], true)) { + // GET/DELETE have no request body — expand form fields as query parameters + $queryParams = $this->requestToQueryParams($operation->request); + if (!empty($queryParams)) { + $pathData['parameters'] = array_merge($pathData['parameters'] ?? [], $queryParams); + } + } else { + $request = []; + $request['content']['application/json']['schema']['$ref'] = + '#/components/schemas/'.$operation->request->id; + $pathData['requestBody'] = $request; + } + } + + if (!empty($security = $operation->security)) { + $pathData['security'] = [$security]; + } + + $pathData['responses'] = $responses; + $data[$operation->path][strtolower($operation->method)] = $pathData; + } + + return $data; + } + + private function requestToQueryParams(Component $component): array + { + $this->excludedComponentIds[] = $component->id; + $params = []; + $required = $component->required ?? []; + + foreach ($component->properties as $property) { + $schema = []; + if (null !== $property->ref) { + $schema['$ref'] = '#/components/schemas/'.$property->ref->id; + } else { + $schema['type'] = $property->type; + if (!empty($property->enum)) { + $schema['enum'] = $property->enum; + } + if (!empty($property->format)) { + $schema['format'] = $property->format; + } + } + + $param = [ + 'name' => $property->name, + 'in' => 'query', + 'required' => in_array($property->name, $required, true), + 'schema' => $schema, + ]; + + $params[] = $param; + } + + return $params; + } +} diff --git a/src/Registry/Registry.php b/src/Registry/Registry.php new file mode 100644 index 0000000..834a061 --- /dev/null +++ b/src/Registry/Registry.php @@ -0,0 +1,24 @@ +id; + if (!isset($this->models[$id])) { + $this->models[$id] = $model; + } + + return $this->models[$id]; + } + + abstract public function getAll(): array; +} diff --git a/src/Registry/RegistryInterface.php b/src/Registry/RegistryInterface.php new file mode 100644 index 0000000..5c67d8e --- /dev/null +++ b/src/Registry/RegistryInterface.php @@ -0,0 +1,14 @@ +getTokenName() === 'T_NAMESPACE') { + for ($j = $i + 1; $j < $tokenCount; $j++) { + if ($tokens[$j]->getTokenName() === 'T_NAME_QUALIFIED' || $tokens[$j]->getTokenName() === 'T_STRING') { + $namespace = $tokens[$j]->text; + break; + } + } + } + + if ($tokens[$i]->getTokenName() === 'T_CLASS' && $tokens[$i - 1]->getTokenName() !== 'T_DOUBLE_COLON') { + for ($j = $i + 1; $j < $tokenCount; $j++) { + if ($tokens[$j]->getTokenName() === 'T_WHITESPACE') { + continue; + } + + if ($tokens[$j]->getTokenName() === 'T_STRING') { + return $namespace.'\\'.$tokens[$j]->text; + } + + break; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Utils/TypeConverter.php b/src/Utils/TypeConverter.php new file mode 100644 index 0000000..e50383a --- /dev/null +++ b/src/Utils/TypeConverter.php @@ -0,0 +1,60 @@ + 'integer', + 'float' => 'number', + 'bool' => 'boolean', + 'string' => 'string', + 'array', 'iterable' => 'array', + default => null, + }; + } + + /** + * Maps a class name to an OpenAPI property definition array for types + * that the Symfony serializer handles as primitives (backed enums, UUIDs, + * date-times). Returns null if the class should be described as a $ref component. + * + * @return array{type: string, format?: string, enum?: list}|null + */ + public function toOpenApiProperty(string $phpClass): ?array + { + if (enum_exists($phpClass) && is_a($phpClass, \BackedEnum::class, true)) { + $backingType = (new ReflectionEnum($phpClass))->getBackingType()->getName(); + /** @var list<\BackedEnum> $cases */ + $cases = $phpClass::cases(); + + return [ + 'type' => $backingType === 'int' ? 'integer' : 'string', + 'enum' => array_map(static fn(\BackedEnum $c) => $c->value, $cases), + ]; + } + + if (class_exists(AbstractUid::class) && is_a($phpClass, AbstractUid::class, true)) { + return ['type' => 'string', 'format' => 'uuid']; + } + + if (is_a($phpClass, DateTimeInterface::class, true) || is_a($phpClass, DateTimeImmutable::class, true)) { + return ['type' => 'string', 'format' => 'date-time']; + } + + return null; + } +} diff --git a/src/config/services.yaml b/src/config/services.yaml new file mode 100644 index 0000000..44c58d5 --- /dev/null +++ b/src/config/services.yaml @@ -0,0 +1,51 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + _instanceof: + ChamberOrchestra\OpenApiDocBundle\Parser\ComponentParserInterface: + tags: [ 'openapi_doc.component_parser' ] + ChamberOrchestra\OpenApiDocBundle\Parser\OperationParserInterface: + tags: [ 'openapi_doc.operation_parser' ] + ChamberOrchestra\OpenApiDocBundle\Parser\FormTypeHandler\FormTypeHandlerInterface: + tags: [ 'openapi_doc.form_type_handler' ] + + # SecurityParser must run before OperationParser so that $model->security['default'] + # is populated before OperationParser merges security schemes from #[Operation]. + ChamberOrchestra\OpenApiDocBundle\Parser\SecurityParser: + tags: + - { name: 'openapi_doc.operation_parser', priority: 10 } + ChamberOrchestra\OpenApiDocBundle\Parser\OperationParser: + tags: + - { name: 'openapi_doc.operation_parser', priority: 0 } + + ChamberOrchestra\OpenApiDocBundle\: + resource: '../*' + + component.registry: '@ChamberOrchestra\OpenApiDocBundle\Registry\ComponentRegistry' + operation.registry: '@ChamberOrchestra\OpenApiDocBundle\Registry\OperationRegistry' + + ChamberOrchestra\OpenApiDocBundle\Describer\DescriberInterface: '@ChamberOrchestra\OpenApiDocBundle\Describer\ComponentDescriber' + + ChamberOrchestra\OpenApiDocBundle\Describer\ComponentDescriber: + arguments: + $parsers: !tagged_iterator { tag: 'openapi_doc.component_parser' } + $registry: '@component.registry' + ChamberOrchestra\OpenApiDocBundle\Describer\OperationDescriber: + arguments: + $parsers: !tagged_iterator { tag: 'openapi_doc.operation_parser' } + $registry: '@operation.registry' + + ChamberOrchestra\OpenApiDocBundle\Parser\FormParser: + arguments: + $handlers: !tagged_iterator { tag: 'openapi_doc.form_type_handler' } + + ChamberOrchestra\OpenApiDocBundle\Parser\FormTypeHandler\CollectionTypeHandler: + arguments: + $handlers: !tagged_iterator { tag: 'openapi_doc.form_type_handler' } + + ChamberOrchestra\OpenApiDocBundle\Parser\FormTypeHandler\RepeatedTypeHandler: + arguments: + $handlers: !tagged_iterator { tag: 'openapi_doc.form_type_handler' } diff --git a/tests/Fixtures/Action/ActionWithoutOperation.php b/tests/Fixtures/Action/ActionWithoutOperation.php new file mode 100644 index 0000000..4789245 --- /dev/null +++ b/tests/Fixtures/Action/ActionWithoutOperation.php @@ -0,0 +1,13 @@ +add('status', ChoiceType::class, [ + 'choices' => ['Active' => 'active', 'Inactive' => 'inactive'], + ]) + ->add('tags', ChoiceType::class, [ + 'multiple' => true, + 'choices' => ['PHP' => 'php', 'Python' => 'python'], + ]); + } +} diff --git a/tests/Fixtures/Form/CollectionFormType.php b/tests/Fixtures/Form/CollectionFormType.php new file mode 100644 index 0000000..8ed0bb2 --- /dev/null +++ b/tests/Fixtures/Form/CollectionFormType.php @@ -0,0 +1,21 @@ +add('items', CollectionType::class, [ + 'entry_type' => SimpleFormType::class, + 'entry_options' => ['required' => false], + ]); + } +} diff --git a/tests/Fixtures/Form/ConstrainedFormType.php b/tests/Fixtures/Form/ConstrainedFormType.php new file mode 100644 index 0000000..bbd1b67 --- /dev/null +++ b/tests/Fixtures/Form/ConstrainedFormType.php @@ -0,0 +1,41 @@ +add('email', TextType::class, [ + 'constraints' => [new Email(), new Length(min: 3, max: 255)], + ]) + ->add('website', TextType::class, [ + 'required' => false, + 'constraints' => [new Url()], + ]) + ->add('username', TextType::class, [ + 'constraints' => [new Regex(pattern: '/^[a-z]+$/')], + ]) + ->add('score', NumberType::class, [ + 'constraints' => [new Range(min: 0, max: 100)], + ]) + ->add('count', IntegerType::class, [ + 'constraints' => [new Positive()], + ]); + } +} diff --git a/tests/Fixtures/Form/EnumChoiceFormType.php b/tests/Fixtures/Form/EnumChoiceFormType.php new file mode 100644 index 0000000..319288b --- /dev/null +++ b/tests/Fixtures/Form/EnumChoiceFormType.php @@ -0,0 +1,39 @@ +add('title', TextType::class, [ + 'constraints' => [new NotBlank(), new Length(min: 1, max: 255)], + ]) + ->add('priorities', ChoiceType::class, [ + 'multiple' => true, + 'choices' => Priority::cases(), + 'choice_value' => 'value', + 'constraints' => [new NotBlank(), new Count(min: 1, max: 3)], + ]) + ->add('primaryPriority', ChoiceType::class, [ + 'choices' => Priority::cases(), + 'choice_value' => 'value', + ]); + } +} diff --git a/tests/Fixtures/Form/NestedFormType.php b/tests/Fixtures/Form/NestedFormType.php new file mode 100644 index 0000000..2b3a3cc --- /dev/null +++ b/tests/Fixtures/Form/NestedFormType.php @@ -0,0 +1,19 @@ +add('label', TextType::class) + ->add('simple', SimpleFormType::class); + } +} diff --git a/tests/Fixtures/Form/RecursiveFormType.php b/tests/Fixtures/Form/RecursiveFormType.php new file mode 100644 index 0000000..3baabfd --- /dev/null +++ b/tests/Fixtures/Form/RecursiveFormType.php @@ -0,0 +1,26 @@ +add('name', TextType::class) + ->add('children', CollectionType::class, [ + 'entry_type' => self::class, + 'entry_options' => ['required' => false], + ]); + } +} diff --git a/tests/Fixtures/Form/SimpleFormType.php b/tests/Fixtures/Form/SimpleFormType.php new file mode 100644 index 0000000..1ce49ca --- /dev/null +++ b/tests/Fixtures/Form/SimpleFormType.php @@ -0,0 +1,20 @@ +add('name', TextType::class) + ->add('age', IntegerType::class, ['required' => false]); + } +} diff --git a/tests/Fixtures/View/IterableContainerView.php b/tests/Fixtures/View/IterableContainerView.php new file mode 100644 index 0000000..80132d6 --- /dev/null +++ b/tests/Fixtures/View/IterableContainerView.php @@ -0,0 +1,17 @@ +registry = new ComponentRegistry(); + + // Use ArrayObject so we can break the circular dependency: + // ComponentDescriber needs parsers, but parsers need ComponentDescriber. + // We create the ArrayObject first, pass it to ComponentDescriber, + // then append parsers to it after creating them with the describer reference. + $this->parsersContainer = new ArrayObject(); + + $this->describer = new ComponentDescriber($this->parsersContainer, $this->registry); + + $propertyParser = new PropertyParser(); + $typeConverter = new TypeConverter(); + + $viewParser = new ViewParser($this->describer, $propertyParser, $typeConverter); + $objectParser = new ObjectParser($propertyParser, $typeConverter, $this->describer); + + $this->parsersContainer->append($viewParser); + $this->parsersContainer->append($objectParser); + } +} diff --git a/tests/Integrational/Parser/FormParserTest.php b/tests/Integrational/Parser/FormParserTest.php new file mode 100644 index 0000000..c2748f0 --- /dev/null +++ b/tests/Integrational/Parser/FormParserTest.php @@ -0,0 +1,360 @@ +addExtension(new ValidatorExtension(Validation::createValidator())) + ->getFormFactory(); + + // Build handlers with an ArrayObject so Collection/Repeated can reference + // the same iterable without a circular constructor dependency. + $handlersContainer = new ArrayObject(); + + $collectionHandler = new CollectionTypeHandler($formFactory, $handlersContainer, $this->describer); + $repeatedHandler = new RepeatedTypeHandler($formFactory, $handlersContainer, $this->describer); + + $handlersContainer->append(new TextTypeHandler()); + $handlersContainer->append(new NumberTypeHandler()); + $handlersContainer->append(new DateTypeHandler()); + $handlersContainer->append(new DateTimeTypeHandler()); + $handlersContainer->append(new CheckboxTypeHandler()); + $handlersContainer->append(new PasswordTypeHandler()); + $handlersContainer->append(new FileTypeHandler()); + $handlersContainer->append(new ChoiceTypeHandler()); + $handlersContainer->append(new EntityTypeHandler()); + $handlersContainer->append($collectionHandler); + $handlersContainer->append($repeatedHandler); + + $this->formParser = new FormParser($formFactory, $this->describer, $handlersContainer); + + // Register FormParser so ComponentDescriber can delegate to it when it + // encounters a FormTypeInterface class (used by nested form resolution). + $this->parsersContainer->append($this->formParser); + } + + // ------------------------------------------------------------------------- + // SimpleFormType + // ------------------------------------------------------------------------- + + public function testSimpleFormTypeProducesSchema(): void + { + $component = $this->describer->describe(SimpleFormType::class); + + self::assertSame('SimpleFormType', $component->id); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('name', $properties); + self::assertSame('string', $properties['name']->type); + + self::assertArrayHasKey('age', $properties); + self::assertSame('integer', $properties['age']->type); + } + + public function testSimpleFormTypeRequiredFields(): void + { + $component = $this->describer->describe(SimpleFormType::class); + + self::assertContains('name', $component->required); + self::assertNotContains('age', $component->required); + } + + // ------------------------------------------------------------------------- + // ConstrainedFormType + // ------------------------------------------------------------------------- + + public function testConstrainedFormTypeEmailField(): void + { + $component = $this->describer->describe(ConstrainedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('email', $properties); + self::assertSame('string', $properties['email']->type); + self::assertSame('email', $properties['email']->format); + self::assertSame(3, $properties['email']->attributes['minLength']); + self::assertSame(255, $properties['email']->attributes['maxLength']); + } + + public function testConstrainedFormTypeWebsiteField(): void + { + $component = $this->describer->describe(ConstrainedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('website', $properties); + self::assertSame('uri', $properties['website']->format); + self::assertNotContains('website', $component->required); + } + + public function testConstrainedFormTypeUsernameField(): void + { + $component = $this->describer->describe(ConstrainedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('username', $properties); + self::assertSame('/^[a-z]+$/', $properties['username']->attributes['pattern']); + } + + public function testConstrainedFormTypeScoreField(): void + { + $component = $this->describer->describe(ConstrainedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('score', $properties); + self::assertSame('number', $properties['score']->type); + self::assertSame(0, $properties['score']->attributes['minimum']); + self::assertSame(100, $properties['score']->attributes['maximum']); + } + + public function testConstrainedFormTypeCountField(): void + { + $component = $this->describer->describe(ConstrainedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('count', $properties); + self::assertSame('integer', $properties['count']->type); + self::assertSame(1, $properties['count']->attributes['minimum']); + } + + // ------------------------------------------------------------------------- + // ChoiceFormType + // ------------------------------------------------------------------------- + + public function testChoiceFormTypeSingleChoice(): void + { + $component = $this->describer->describe(ChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('status', $properties); + self::assertSame('string', $properties['status']->type); + self::assertSame(['active', 'inactive'], $properties['status']->enum); + } + + public function testChoiceFormTypeMultipleChoice(): void + { + $component = $this->describer->describe(ChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('tags', $properties); + self::assertSame('array', $properties['tags']->type); + self::assertNotNull($properties['tags']->items); + self::assertSame(['php', 'python'], $properties['tags']->items->enum); + } + + // ------------------------------------------------------------------------- + // NestedFormType + // ------------------------------------------------------------------------- + + public function testNestedFormTypeHasLabelField(): void + { + $component = $this->describer->describe(NestedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('label', $properties); + self::assertSame('string', $properties['label']->type); + } + + public function testNestedFormTypeHasRefToSimpleForm(): void + { + $component = $this->describer->describe(NestedFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('simple', $properties); + $simpleProp = $properties['simple']; + self::assertNotNull($simpleProp->ref); + self::assertSame('SimpleFormType', $simpleProp->ref->id); + } + + public function testNestedFormTypeRegistersChildComponent(): void + { + $this->describer->describe(NestedFormType::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('NestedFormType', $result['schemas']); + self::assertArrayHasKey('SimpleFormType', $result['schemas']); + } + + public function testNestedFormTypeOutputRefSchema(): void + { + $this->describer->describe(NestedFormType::class); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['NestedFormType']; + + self::assertSame( + '#/components/schemas/SimpleFormType', + $schema['properties']['simple']['$ref'] + ); + } + + // ------------------------------------------------------------------------- + // CollectionFormType + // ------------------------------------------------------------------------- + + public function testCollectionFormTypeProducesArrayProperty(): void + { + $component = $this->describer->describe(CollectionFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('items', $properties); + self::assertSame('array', $properties['items']->type); + } + + public function testCollectionFormTypeItemsRefSimpleForm(): void + { + $component = $this->describer->describe(CollectionFormType::class); + $properties = $this->indexByName($component->properties); + + $itemsProp = $properties['items']; + self::assertNotNull($itemsProp->items); + self::assertNotNull($itemsProp->items->ref); + self::assertSame('SimpleFormType', $itemsProp->items->ref->id); + } + + public function testCollectionFormTypeRegistersEntryTypeComponent(): void + { + $this->describer->describe(CollectionFormType::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('SimpleFormType', $result['schemas']); + } + + // ------------------------------------------------------------------------- + // EnumChoiceFormType + // ------------------------------------------------------------------------- + + public function testEnumChoiceFormTypeTextFieldWithConstraints(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('title', $properties); + self::assertSame('string', $properties['title']->type); + self::assertSame(1, $properties['title']->attributes['minLength']); + self::assertSame(255, $properties['title']->attributes['maxLength']); + } + + public function testEnumChoiceFormTypeMultipleFieldUsesEnumValues(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('priorities', $properties); + $prop = $properties['priorities']; + self::assertSame('array', $prop->type); + self::assertNotNull($prop->items); + self::assertSame('string', $prop->items->type); + self::assertSame(['low', 'medium', 'high'], $prop->items->enum); + } + + public function testEnumChoiceFormTypeMultipleFieldHasCountConstraints(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + $prop = $properties['priorities']; + self::assertSame(1, $prop->attributes['minItems']); + self::assertSame(3, $prop->attributes['maxItems']); + } + + public function testEnumChoiceFormTypeSingleFieldUsesEnumValues(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('primaryPriority', $properties); + $prop = $properties['primaryPriority']; + self::assertSame('string', $prop->type); + self::assertSame(['low', 'medium', 'high'], $prop->enum); + } + + public function testEnumChoiceFormTypeSingleFieldHasNoMinItems(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + $properties = $this->indexByName($component->properties); + + self::assertArrayNotHasKey('minItems', $properties['primaryPriority']->attributes); + } + + public function testEnumChoiceFormTypeRequiredFields(): void + { + $component = $this->describer->describe(EnumChoiceFormType::class); + + self::assertContains('priorities', $component->required); + self::assertContains('title', $component->required); + } + + // ------------------------------------------------------------------------- + // RecursiveFormType — self-referential form (cycle detection) + // ------------------------------------------------------------------------- + + public function testRecursiveFormTypeDoesNotCauseInfiniteLoop(): void + { + $component = $this->describer->describe(RecursiveFormType::class); + + self::assertSame('RecursiveFormType', $component->id); + } + + public function testRecursiveFormTypeChildrenRefPointsToItself(): void + { + $this->describer->describe(RecursiveFormType::class); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['RecursiveFormType']; + + self::assertSame( + '#/components/schemas/RecursiveFormType', + $schema['properties']['children']['items']['$ref'] + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function indexByName(array $properties): array + { + $indexed = []; + foreach ($properties as $property) { + $indexed[$property->name] = $property; + } + + return $indexed; + } +} diff --git a/tests/Integrational/Parser/ObjectParserTest.php b/tests/Integrational/Parser/ObjectParserTest.php new file mode 100644 index 0000000..4531c53 --- /dev/null +++ b/tests/Integrational/Parser/ObjectParserTest.php @@ -0,0 +1,114 @@ +describer->describe(SimpleDto::class); + + self::assertSame('SimpleDto', $component->id); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('name', $properties); + self::assertSame('string', $properties['name']->type); + + self::assertArrayHasKey('count', $properties); + self::assertSame('integer', $properties['count']->type); + + self::assertArrayHasKey('active', $properties); + self::assertSame('boolean', $properties['active']->type); + + self::assertArrayHasKey('ratio', $properties); + self::assertSame('number', $properties['ratio']->type); + } + + public function testSimpleDtoIsRegisteredInRegistry(): void + { + $this->describer->describe(SimpleDto::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('SimpleDto', $result['schemas']); + } + + public function testSimpleDtoOutputSchema(): void + { + $this->describer->describe(SimpleDto::class); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['SimpleDto']; + + self::assertSame('string', $schema['properties']['name']['type']); + self::assertSame('integer', $schema['properties']['count']['type']); + self::assertSame('boolean', $schema['properties']['active']['type']); + self::assertSame('number', $schema['properties']['ratio']['type']); + } + + public function testNestedDtoCreatesRefToChild(): void + { + $component = $this->describer->describe(NestedDto::class); + + self::assertSame('NestedDto', $component->id); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('title', $properties); + self::assertSame('string', $properties['title']->type); + + self::assertArrayHasKey('nested', $properties); + $nestedProperty = $properties['nested']; + self::assertSame('object', $nestedProperty->type); + self::assertNotNull($nestedProperty->ref); + self::assertSame('SimpleDto', $nestedProperty->ref->id); + } + + public function testNestedDtoRegistersChildComponent(): void + { + $this->describer->describe(NestedDto::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('NestedDto', $result['schemas']); + self::assertArrayHasKey('SimpleDto', $result['schemas']); + } + + public function testNestedDtoOutputRefSchema(): void + { + $this->describer->describe(NestedDto::class); + + $result = $this->registry->getAll(); + $nestedSchema = $result['schemas']['NestedDto']; + + self::assertSame( + '#/components/schemas/SimpleDto', + $nestedSchema['properties']['nested']['$ref'] + ); + } + + public function testDuplicateDescribeReturnsSameInstance(): void + { + $first = $this->describer->describe(SimpleDto::class); + $second = $this->describer->describe(SimpleDto::class); + + self::assertSame($first, $second); + } + + private function indexByName(array $properties): array + { + $indexed = []; + foreach ($properties as $property) { + $indexed[$property->name] = $property; + } + + return $indexed; + } +} diff --git a/tests/Integrational/Parser/ViewParserTest.php b/tests/Integrational/Parser/ViewParserTest.php new file mode 100644 index 0000000..c300d30 --- /dev/null +++ b/tests/Integrational/Parser/ViewParserTest.php @@ -0,0 +1,177 @@ +describer->describe(SimpleView::class); + + self::assertSame('SimpleView', $component->id); + self::assertNull($component->type); // plain object, not 'array' + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('name', $properties); + self::assertSame('string', $properties['name']->type); + + self::assertArrayHasKey('age', $properties); + self::assertSame('integer', $properties['age']->type); + + self::assertArrayHasKey('active', $properties); + self::assertSame('boolean', $properties['active']->type); + + self::assertArrayHasKey('score', $properties); + self::assertSame('number', $properties['score']->type); + } + + public function testSimpleViewIsRegisteredInRegistry(): void + { + $this->describer->describe(SimpleView::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('SimpleView', $result['schemas']); + } + + public function testNestedViewCreatesRefToChild(): void + { + $component = $this->describer->describe(NestedView::class); + + self::assertSame('NestedView', $component->id); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('title', $properties); + self::assertSame('string', $properties['title']->type); + + self::assertArrayHasKey('simple', $properties); + $simpleProperty = $properties['simple']; + self::assertSame('object', $simpleProperty->type); + self::assertNotNull($simpleProperty->ref); + self::assertSame('SimpleView', $simpleProperty->ref->id); + } + + public function testNestedViewRegistersChildComponent(): void + { + $this->describer->describe(NestedView::class); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('NestedView', $result['schemas']); + self::assertArrayHasKey('SimpleView', $result['schemas']); + } + + public function testNestedViewRefInOutput(): void + { + $this->describer->describe(NestedView::class); + + $result = $this->registry->getAll(); + $nestedSchema = $result['schemas']['NestedView']; + + self::assertSame( + '#/components/schemas/SimpleView', + $nestedSchema['properties']['simple']['$ref'] + ); + } + + public function testIterableContainerViewProducesStringProperty(): void + { + $component = $this->describer->describe(IterableContainerView::class); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('title', $properties); + self::assertSame('string', $properties['title']->type); + } + + public function testIterableContainerViewProducesArrayProperty(): void + { + $component = $this->describer->describe(IterableContainerView::class); + + $properties = $this->indexByName($component->properties); + + self::assertArrayHasKey('items', $properties); + $itemsProp = $properties['items']; + self::assertSame('array', $itemsProp->type); + self::assertNotNull($itemsProp->items); + self::assertSame('SimpleView', $itemsProp->items->ref->id); + } + + public function testIterableContainerViewOutputSchema(): void + { + $this->describer->describe(IterableContainerView::class); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['IterableContainerView']; + + self::assertSame('string', $schema['properties']['title']['type']); + + $itemsSchema = $schema['properties']['items']; + self::assertSame('array', $itemsSchema['type']); + self::assertSame('#/components/schemas/SimpleView', $itemsSchema['items']['$ref']); + } + + public function testDuplicateDescribeReturnsSameComponent(): void + { + $first = $this->describer->describe(SimpleView::class); + $second = $this->describer->describe(SimpleView::class); + + self::assertSame($first, $second); + } + + // ------------------------------------------------------------------------- + // RecursiveView — self-referential tree structure + // ------------------------------------------------------------------------- + + public function testRecursiveViewDoesNotCauseInfiniteLoop(): void + { + $component = $this->describer->describe(RecursiveView::class); + + self::assertSame('RecursiveView', $component->id); + } + + public function testRecursiveViewChildrenRefPointsToItself(): void + { + $this->describer->describe(RecursiveView::class); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['RecursiveView']; + + self::assertSame( + '#/components/schemas/RecursiveView', + $schema['properties']['children']['items']['$ref'] + ); + } + + public function testRecursiveViewRegisteredOnce(): void + { + $first = $this->describer->describe(RecursiveView::class); + $second = $this->describer->describe(RecursiveView::class); + + self::assertSame($first, $second); + self::assertCount(1, array_filter( + array_keys($this->registry->getAll()['schemas']), + fn($k) => $k === 'RecursiveView' + )); + } + + private function indexByName(array $properties): array + { + $indexed = []; + foreach ($properties as $property) { + $indexed[$property->name] = $property; + } + + return $indexed; + } +} diff --git a/tests/Unit/Builder/DocumentBuilderTest.php b/tests/Unit/Builder/DocumentBuilderTest.php new file mode 100644 index 0000000..0d953c8 --- /dev/null +++ b/tests/Unit/Builder/DocumentBuilderTest.php @@ -0,0 +1,177 @@ +operationRegistry = new OperationRegistry(); + $this->componentRegistry = new ComponentRegistry(); + $this->builder = new DocumentBuilder($this->operationRegistry, $this->componentRegistry); + } + + public function testBuildReturnsOpenApiStructure(): void + { + $result = $this->builder->build(); + + self::assertSame('3.0.1', $result['openapi']); + self::assertSame('1.0.0', $result['info']['version']); + self::assertSame('API Documentation', $result['info']['title']); + self::assertArrayHasKey('paths', $result); + self::assertArrayHasKey('components', $result); + } + + public function testBuildWithCustomVersionAndTitle(): void + { + $result = $this->builder->build([], '2.5.0', 'My API'); + + self::assertSame('2.5.0', $result['info']['version']); + self::assertSame('My API', $result['info']['title']); + } + + public function testBuildIncludesRegisteredOperation(): void + { + $operation = new Operation(); + $operation->id = 'listUsers'; + $operation->path = '/users'; + $operation->method = 'GET'; + $this->operationRegistry->register($operation); + + $result = $this->builder->build(); + + self::assertArrayHasKey('/users', $result['paths']); + } + + public function testBuildIncludesRegisteredComponent(): void + { + $component = new Component(); + $component->id = 'UserView'; + $component->properties = [Property::factory('name', 'string')]; + $this->componentRegistry->register($component); + + $result = $this->builder->build(); + + self::assertArrayHasKey('UserView', $result['components']['schemas']); + } + + public function testBuildMergesProtoSecuritySchemes(): void + { + $protoData = [ + 'components' => [ + 'securitySchemes' => [ + 'BearerAuth' => ['type' => 'http', 'scheme' => 'bearer'], + ], + ], + ]; + + $result = $this->builder->build($protoData); + + self::assertArrayHasKey('BearerAuth', $result['components']['securitySchemes']); + } + + public function testBuildMergesProtoSchemas(): void + { + $protoData = [ + 'components' => [ + 'schemas' => [ + 'Error' => ['type' => 'object', 'properties' => ['message' => ['type' => 'string']]], + ], + ], + ]; + + $result = $this->builder->build($protoData); + + self::assertArrayHasKey('Error', $result['components']['schemas']); + } + + public function testBuildMergesProtoResponses(): void + { + $protoData = [ + 'components' => [ + 'responses' => [ + 'NotFound' => ['description' => 'Not found'], + ], + ], + ]; + + $result = $this->builder->build($protoData); + + self::assertArrayHasKey('NotFound', $result['components']['responses']); + } + + public function testBuildResolvesDefaultSecurityToFirstScheme(): void + { + $operation = new Operation(); + $operation->id = 'securedOp'; + $operation->path = '/secured'; + $operation->method = 'GET'; + $operation->security = ['default' => []]; + $this->operationRegistry->register($operation); + + $protoData = [ + 'components' => [ + 'securitySchemes' => [ + 'BearerAuth' => ['type' => 'http', 'scheme' => 'bearer'], + ], + ], + ]; + + $result = $this->builder->build($protoData); + $security = $result['paths']['/secured']['get']['security']; + + self::assertArrayHasKey('BearerAuth', $security[0]); + self::assertArrayNotHasKey('default', $security[0]); + } + + public function testBuildStripsDefaultSecurityWhenNoProtoSchemes(): void + { + $operation = new Operation(); + $operation->id = 'securedOp'; + $operation->path = '/secured'; + $operation->method = 'GET'; + $operation->security = ['default' => []]; + $this->operationRegistry->register($operation); + + $result = $this->builder->build(); + $op = $result['paths']['/secured']['get']; + + // 'default' is a placeholder — stripped when no securitySchemes are defined + self::assertArrayNotHasKey('security', $op); + } + + public function testProtoSchemasAreMergedWithGeneratedOnes(): void + { + $component = new Component(); + $component->id = 'GeneratedModel'; + $component->properties = [Property::factory('id', 'integer')]; + $this->componentRegistry->register($component); + + $protoData = [ + 'components' => [ + 'schemas' => [ + 'ProtoModel' => ['type' => 'object'], + ], + ], + ]; + + $result = $this->builder->build($protoData); + + self::assertArrayHasKey('GeneratedModel', $result['components']['schemas']); + self::assertArrayHasKey('ProtoModel', $result['components']['schemas']); + } +} diff --git a/tests/Unit/Locator/LocatorTest.php b/tests/Unit/Locator/LocatorTest.php new file mode 100644 index 0000000..1f48564 --- /dev/null +++ b/tests/Unit/Locator/LocatorTest.php @@ -0,0 +1,96 @@ +locator = new Locator(new ClassParser()); + } + + public function testLocatesActionWithBothAttributes(): void + { + $path = __DIR__ . '/../../Fixtures/Action'; + $classes = iterator_to_array($this->locator->locate($path), false); + + self::assertContains(SimpleAction::class, $classes); + } + + public function testDoesNotLocateActionWithoutRoute(): void + { + $path = __DIR__ . '/../../Fixtures/Action'; + $classes = iterator_to_array($this->locator->locate($path), false); + + self::assertNotContains( + 'ChamberOrchestra\OpenApiDocBundle\Tests\Fixtures\Action\ActionWithoutRoute', + $classes + ); + } + + public function testDoesNotLocateActionWithoutOperation(): void + { + $path = __DIR__ . '/../../Fixtures/Action'; + $classes = iterator_to_array($this->locator->locate($path), false); + + self::assertNotContains( + 'ChamberOrchestra\OpenApiDocBundle\Tests\Fixtures\Action\ActionWithoutOperation', + $classes + ); + } + + public function testReturnsOnlyQualifyingClasses(): void + { + $path = __DIR__ . '/../../Fixtures/Action'; + $classes = iterator_to_array($this->locator->locate($path), false); + + self::assertCount(1, $classes); + } + + public function testIgnoresNonPhpFiles(): void + { + // Create a temp dir with a non-PHP file and a PHP action file + $tmpDir = sys_get_temp_dir() . '/locator_test_' . uniqid(); + mkdir($tmpDir); + + // Write a README that should be ignored + file_put_contents($tmpDir . '/README.md', '# docs'); + + // Copy the SimpleAction fixture + copy( + __DIR__ . '/../../Fixtures/Action/SimpleAction.php', + $tmpDir . '/SimpleAction.php' + ); + + $classes = iterator_to_array($this->locator->locate($tmpDir), false); + + // Clean up + array_map('unlink', glob($tmpDir . '/*')); + rmdir($tmpDir); + + // The SimpleAction fixture class is already loaded; its FQCN won't change + // just assert no error was thrown and result is iterable + self::assertIsArray($classes); + } + + public function testEmptyDirectoryReturnsEmpty(): void + { + $tmpDir = sys_get_temp_dir() . '/locator_empty_' . uniqid(); + mkdir($tmpDir); + + $classes = iterator_to_array($this->locator->locate($tmpDir), false); + + rmdir($tmpDir); + + self::assertSame([], $classes); + } +} diff --git a/tests/Unit/Parser/FormTypeHandler/ChoiceTypeHandlerTest.php b/tests/Unit/Parser/FormTypeHandler/ChoiceTypeHandlerTest.php new file mode 100644 index 0000000..130e5a8 --- /dev/null +++ b/tests/Unit/Parser/FormTypeHandler/ChoiceTypeHandlerTest.php @@ -0,0 +1,147 @@ +handler = new ChoiceTypeHandler(); + } + + public function testSupportsChoice(): void + { + self::assertTrue($this->handler->supports('choice')); + } + + public function testDoesNotSupportOtherPrefixes(): void + { + self::assertFalse($this->handler->supports('text')); + self::assertFalse($this->handler->supports('integer')); + self::assertFalse($this->handler->supports('collection')); + } + + public function testHandleSingleChoiceWithStringValues(): void + { + $property = Property::factory('status'); + $config = $this->makeConfig(false, ['Active' => 'active', 'Inactive' => 'inactive']); + + $this->handler->handle($property, $config); + + self::assertSame('string', $property->type); + self::assertSame(['active', 'inactive'], $property->enum); + } + + public function testHandleMultipleChoiceWithStringValues(): void + { + $property = Property::factory('tags'); + $config = $this->makeConfig(true, ['PHP' => 'php', 'Python' => 'python']); + + $this->handler->handle($property, $config); + + self::assertSame('array', $property->type); + self::assertNotNull($property->items); + self::assertSame('string', $property->items->type); + self::assertSame(['php', 'python'], $property->items->enum); + } + + public function testHandleSingleChoiceWithNumericValues(): void + { + $property = Property::factory('priority'); + $config = $this->makeConfig(false, ['Low' => 1, 'High' => 10]); + + $this->handler->handle($property, $config); + + self::assertSame('number', $property->type); + self::assertSame([1, 10], $property->enum); + } + + public function testHandleSingleChoiceWithBooleanValues(): void + { + $property = Property::factory('enabled'); + $config = $this->makeConfig(false, ['Yes' => true, 'No' => false]); + + $this->handler->handle($property, $config); + + self::assertSame('boolean', $property->type); + self::assertSame([true, false], $property->enum); + } + + public function testHandleMultipleChoiceWithNoChoices(): void + { + $property = Property::factory('tags'); + $config = $this->makeConfig(true, []); + + $this->handler->handle($property, $config); + + self::assertSame('array', $property->type); + self::assertNull($property->items); + self::assertSame([], $property->enum); + } + + public function testHandleSingleChoiceWithNoChoices(): void + { + $property = Property::factory('status'); + $config = $this->makeConfig(false, []); + + $this->handler->handle($property, $config); + + self::assertSame('string', $property->type); + self::assertSame([], $property->enum); + } + + public function testHandleMultipleChoiceWithCountMinConstraint(): void + { + $property = Property::factory('tags'); + $config = $this->makeConfig(true, [], [new Count(min: 1)]); + + $this->handler->handle($property, $config); + + self::assertSame('array', $property->type); + self::assertSame(1, $property->attributes['minItems']); + self::assertArrayNotHasKey('maxItems', $property->attributes); + } + + public function testHandleMultipleChoiceWithCountMinMaxConstraint(): void + { + $property = Property::factory('tags'); + $config = $this->makeConfig(true, [], [new Count(min: 1, max: 5)]); + + $this->handler->handle($property, $config); + + self::assertSame(1, $property->attributes['minItems']); + self::assertSame(5, $property->attributes['maxItems']); + } + + public function testCountConstraintIgnoredForSingleChoice(): void + { + $property = Property::factory('status'); + $config = $this->makeConfig(false, [], [new Count(min: 1)]); + + $this->handler->handle($property, $config); + + self::assertArrayNotHasKey('minItems', $property->attributes); + } + + private function makeConfig(bool $multiple, array $choices, array $constraints = []): FormConfigInterface + { + $config = $this->createMock(FormConfigInterface::class); + $config->method('getOption')->willReturnMap([ + ['multiple', null, null, $multiple], + ['choices', null, null, $choices], + ['constraints', null, null, $constraints], + ]); + + return $config; + } +} diff --git a/tests/Unit/Parser/FormTypeHandler/NumberTypeHandlerTest.php b/tests/Unit/Parser/FormTypeHandler/NumberTypeHandlerTest.php new file mode 100644 index 0000000..0cbb74a --- /dev/null +++ b/tests/Unit/Parser/FormTypeHandler/NumberTypeHandlerTest.php @@ -0,0 +1,122 @@ +handler = new NumberTypeHandler(); + } + + public function testSupportsNumberAndInteger(): void + { + self::assertTrue($this->handler->supports('number')); + self::assertTrue($this->handler->supports('integer')); + } + + public function testDoesNotSupportOtherPrefixes(): void + { + self::assertFalse($this->handler->supports('text')); + self::assertFalse($this->handler->supports('choice')); + } + + public function testHandleWithNumberBlockPrefix(): void + { + $property = Property::factory('price'); + $config = $this->makeConfig('number', []); + + $this->handler->handle($property, $config); + + self::assertSame('number', $property->type); + } + + public function testHandleWithIntegerBlockPrefix(): void + { + $property = Property::factory('count'); + $config = $this->makeConfig('integer', []); + + $this->handler->handle($property, $config); + + self::assertSame('integer', $property->type); + } + + public function testHandleWithNoConstraints(): void + { + $property = Property::factory('value'); + $config = $this->makeConfig('number', []); + + $this->handler->handle($property, $config); + + self::assertSame([], $property->attributes); + } + + public function testHandleWithPositiveConstraint(): void + { + $property = Property::factory('count'); + $config = $this->makeConfig('integer', [new Positive()]); + + $this->handler->handle($property, $config); + + self::assertSame(1, $property->attributes['minimum']); + } + + public function testHandleWithPositiveOrZeroConstraint(): void + { + $property = Property::factory('count'); + $config = $this->makeConfig('integer', [new PositiveOrZero()]); + + $this->handler->handle($property, $config); + + self::assertSame(0, $property->attributes['minimum']); + } + + public function testHandleWithRangeConstraint(): void + { + $property = Property::factory('score'); + $config = $this->makeConfig('number', [new Range(min: 0, max: 100)]); + + $this->handler->handle($property, $config); + + self::assertSame(0, $property->attributes['minimum']); + self::assertSame(100, $property->attributes['maximum']); + } + + public function testHandleWithRangeMinOnly(): void + { + $property = Property::factory('score'); + $config = $this->makeConfig('number', [new Range(min: 5)]); + + $this->handler->handle($property, $config); + + self::assertSame(5, $property->attributes['minimum']); + self::assertArrayNotHasKey('maximum', $property->attributes); + } + + private function makeConfig(string $blockPrefix, array $constraints): FormConfigInterface + { + $resolvedType = $this->createMock(ResolvedFormTypeInterface::class); + $resolvedType->method('getBlockPrefix')->willReturn($blockPrefix); + + $config = $this->createMock(FormConfigInterface::class); + $config->method('getType')->willReturn($resolvedType); + $config->method('getOption')->willReturnMap([ + ['constraints', null, null, $constraints], + ]); + + return $config; + } +} diff --git a/tests/Unit/Parser/FormTypeHandler/TextTypeHandlerTest.php b/tests/Unit/Parser/FormTypeHandler/TextTypeHandlerTest.php new file mode 100644 index 0000000..438bc64 --- /dev/null +++ b/tests/Unit/Parser/FormTypeHandler/TextTypeHandlerTest.php @@ -0,0 +1,134 @@ +handler = new TextTypeHandler(); + } + + public function testSupportsText(): void + { + self::assertTrue($this->handler->supports('text')); + } + + public function testDoesNotSupportOtherPrefixes(): void + { + self::assertFalse($this->handler->supports('integer')); + self::assertFalse($this->handler->supports('number')); + self::assertFalse($this->handler->supports('choice')); + self::assertFalse($this->handler->supports('password')); + } + + public function testHandleSetsStringType(): void + { + $property = Property::factory('name'); + $config = $this->makeConfig([]); + + $this->handler->handle($property, $config); + + self::assertSame('string', $property->type); + } + + public function testHandleWithNoConstraints(): void + { + $property = Property::factory('name'); + $config = $this->makeConfig([]); + + $this->handler->handle($property, $config); + + self::assertSame('string', $property->type); + self::assertNull($property->format); + self::assertSame([], $property->attributes); + } + + public function testHandleWithLengthConstraint(): void + { + $property = Property::factory('username'); + $config = $this->makeConfig([new Length(min: 3, max: 50)]); + + $this->handler->handle($property, $config); + + self::assertSame(3, $property->attributes['minLength']); + self::assertSame(50, $property->attributes['maxLength']); + } + + public function testHandleWithLengthConstraintMinOnly(): void + { + $property = Property::factory('username'); + $config = $this->makeConfig([new Length(min: 2)]); + + $this->handler->handle($property, $config); + + self::assertSame(2, $property->attributes['minLength']); + self::assertArrayNotHasKey('maxLength', $property->attributes); + } + + public function testHandleWithEmailConstraint(): void + { + $property = Property::factory('email'); + $config = $this->makeConfig([new Email()]); + + $this->handler->handle($property, $config); + + self::assertSame('email', $property->format); + } + + public function testHandleWithUrlConstraint(): void + { + $property = Property::factory('website'); + $config = $this->makeConfig([new Url()]); + + $this->handler->handle($property, $config); + + self::assertSame('uri', $property->format); + } + + public function testHandleWithRegexConstraint(): void + { + $property = Property::factory('slug'); + $config = $this->makeConfig([new Regex(pattern: '/^[a-z0-9-]+$/')]); + + $this->handler->handle($property, $config); + + self::assertSame('/^[a-z0-9-]+$/', $property->attributes['pattern']); + } + + public function testHandleWithMultipleConstraints(): void + { + $property = Property::factory('email'); + $config = $this->makeConfig([ + new Email(), + new Length(min: 5, max: 255), + ]); + + $this->handler->handle($property, $config); + + self::assertSame('email', $property->format); + self::assertSame(5, $property->attributes['minLength']); + self::assertSame(255, $property->attributes['maxLength']); + } + + private function makeConfig(array $constraints): FormConfigInterface + { + $config = $this->createMock(FormConfigInterface::class); + $config->method('getOption')->with('constraints')->willReturn($constraints); + + return $config; + } +} diff --git a/tests/Unit/Registry/ComponentRegistryTest.php b/tests/Unit/Registry/ComponentRegistryTest.php new file mode 100644 index 0000000..bb2e96d --- /dev/null +++ b/tests/Unit/Registry/ComponentRegistryTest.php @@ -0,0 +1,218 @@ +registry = new ComponentRegistry(); + } + + public function testEmptyRegistry(): void + { + self::assertSame(['schemas' => []], $this->registry->getAll()); + } + + public function testSimpleComponent(): void + { + $component = new Component(); + $component->id = 'SimpleModel'; + $component->properties = [Property::factory('name', 'string')]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('SimpleModel', $result['schemas']); + self::assertSame('string', $result['schemas']['SimpleModel']['properties']['name']['type']); + } + + public function testComponentWithMultipleProperties(): void + { + $component = new Component(); + $component->id = 'UserModel'; + $component->properties = [ + Property::factory('name', 'string'), + Property::factory('age', 'integer'), + Property::factory('active', 'boolean'), + ]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['UserModel']; + + self::assertSame('string', $schema['properties']['name']['type']); + self::assertSame('integer', $schema['properties']['age']['type']); + self::assertSame('boolean', $schema['properties']['active']['type']); + } + + public function testComponentWithRequired(): void + { + $component = new Component(); + $component->id = 'RequiredModel'; + $component->properties = [Property::factory('name', 'string')]; + $component->required = ['name']; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + + self::assertSame(['name'], $result['schemas']['RequiredModel']['required']); + } + + public function testComponentWithoutRequiredOmitsField(): void + { + $component = new Component(); + $component->id = 'OptionalModel'; + $component->properties = [Property::factory('name', 'string')]; + // $component->required stays [] + + $this->registry->register($component); + + $result = $this->registry->getAll(); + + self::assertArrayNotHasKey('required', $result['schemas']['OptionalModel']); + } + + public function testPropertyWithRef(): void + { + $refComponent = new Component(); + $refComponent->id = 'RefModel'; + + $component = new Component(); + $component->id = 'ParentModel'; + $refProperty = Property::factory('child', 'object'); + $refProperty->ref = $refComponent; + $component->properties = [$refProperty]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + $childData = $result['schemas']['ParentModel']['properties']['child']; + + self::assertArrayHasKey('$ref', $childData); + self::assertSame('#/components/schemas/RefModel', $childData['$ref']); + self::assertArrayNotHasKey('type', $childData); + } + + public function testArrayTypeComponent(): void + { + $itemRef = new Component(); + $itemRef->id = 'ItemModel'; + + $itemsProperty = Property::factory('items'); + $itemsProperty->ref = $itemRef; + + $component = new Component(); + $component->id = 'MyList'; + $component->type = 'array'; + $component->items = $itemsProperty; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['MyList']; + + self::assertSame('array', $schema['type']); + self::assertSame('#/components/schemas/ItemModel', $schema['items']['$ref']); + } + + public function testDuplicateRegistrationIsIgnored(): void + { + $first = new Component(); + $first->id = 'Duplicate'; + $first->properties = [Property::factory('original', 'string')]; + + $second = new Component(); + $second->id = 'Duplicate'; + $second->properties = [Property::factory('override', 'integer')]; + + $this->registry->register($first); + $this->registry->register($second); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('original', $result['schemas']['Duplicate']['properties']); + self::assertArrayNotHasKey('override', $result['schemas']['Duplicate']['properties']); + } + + public function testPropertyWithFormat(): void + { + $property = Property::factory('email', 'string', 'email'); + + $component = new Component(); + $component->id = 'FormModel'; + $component->properties = [$property]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + + self::assertSame('email', $result['schemas']['FormModel']['properties']['email']['format']); + } + + public function testPropertyWithEnum(): void + { + $property = Property::factory('status', 'string'); + $property->enum = ['active', 'inactive']; + + $component = new Component(); + $component->id = 'StatusModel'; + $component->properties = [$property]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + + self::assertSame(['active', 'inactive'], $result['schemas']['StatusModel']['properties']['status']['enum']); + } + + public function testPropertyWithExtraAttributes(): void + { + $property = Property::factory('username', 'string'); + $property->attributes = ['minLength' => 3, 'maxLength' => 50, 'pattern' => '^[a-z]+$']; + + $component = new Component(); + $component->id = 'UsernameModel'; + $component->properties = [$property]; + + $this->registry->register($component); + + $result = $this->registry->getAll(); + $schema = $result['schemas']['UsernameModel']['properties']['username']; + + self::assertSame(3, $schema['minLength']); + self::assertSame(50, $schema['maxLength']); + self::assertSame('^[a-z]+$', $schema['pattern']); + } + + public function testMultipleComponents(): void + { + $c1 = new Component(); + $c1->id = 'ModelA'; + $c1->properties = [Property::factory('x', 'string')]; + + $c2 = new Component(); + $c2->id = 'ModelB'; + $c2->properties = [Property::factory('y', 'integer')]; + + $this->registry->register($c1); + $this->registry->register($c2); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('ModelA', $result['schemas']); + self::assertArrayHasKey('ModelB', $result['schemas']); + } +} diff --git a/tests/Unit/Registry/OperationRegistryTest.php b/tests/Unit/Registry/OperationRegistryTest.php new file mode 100644 index 0000000..70cdaa4 --- /dev/null +++ b/tests/Unit/Registry/OperationRegistryTest.php @@ -0,0 +1,176 @@ +registry = new OperationRegistry(); + } + + public function testEmptyRegistry(): void + { + self::assertSame([], $this->registry->getAll()); + } + + public function testSimpleOperation(): void + { + $operation = new Operation(); + $operation->id = 'getUser'; + $operation->path = '/users/{id}'; + $operation->method = 'GET'; + $operation->description = 'Get a user'; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('/users/{id}', $result); + self::assertArrayHasKey('get', $result['/users/{id}']); + + $op = $result['/users/{id}']['get']; + + self::assertSame('getUser', $op['operationId']); + self::assertSame('Get a user', $op['description']); + } + + public function testOperationWithoutDescriptionOmitsField(): void + { + $operation = new Operation(); + $operation->id = 'listItems'; + $operation->path = '/items'; + $operation->method = 'GET'; + $operation->description = null; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + $op = $result['/items']['get']; + + self::assertArrayNotHasKey('description', $op); + } + + public function testOperationWithComponentResponse(): void + { + $response = new Component(); + $response->id = 'UserView'; + $response->status = 200; + $response->headers = ['Content-Type' => 'application/json']; + + $operation = new Operation(); + $operation->id = 'getUser'; + $operation->path = '/users'; + $operation->method = 'GET'; + $operation->responses = [200 => $response]; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + $responses = $result['/users']['get']['responses']; + + self::assertArrayHasKey(200, $responses); + self::assertSame('#/components/schemas/UserView', $responses[200]['content']['application/json']['schema']['$ref']); + } + + public function testOperationWithStringResponse(): void + { + $operation = new Operation(); + $operation->id = 'createUser'; + $operation->path = '/users'; + $operation->method = 'POST'; + $operation->responses = ['404' => 'NotFound']; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + $responses = $result['/users']['post']['responses']; + + self::assertArrayHasKey('404', $responses); + self::assertSame('#/components/responses/NotFound', $responses['404']['$ref']); + } + + public function testOperationWithRequestBody(): void + { + $requestComponent = new Component(); + $requestComponent->id = 'UserForm'; + + $operation = new Operation(); + $operation->id = 'createUser'; + $operation->path = '/users'; + $operation->method = 'POST'; + $operation->request = $requestComponent; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + $op = $result['/users']['post']; + + self::assertArrayHasKey('requestBody', $op); + self::assertSame( + '#/components/schemas/UserForm', + $op['requestBody']['content']['application/json']['schema']['$ref'] + ); + } + + public function testOperationWithSecurity(): void + { + $operation = new Operation(); + $operation->id = 'securedEndpoint'; + $operation->path = '/secured'; + $operation->method = 'GET'; + $operation->security = ['default' => []]; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + $op = $result['/secured']['get']; + + self::assertSame([['default' => []]], $op['security']); + } + + public function testMethodIsLowercased(): void + { + $operation = new Operation(); + $operation->id = 'myEndpoint'; + $operation->path = '/test'; + $operation->method = 'DELETE'; + + $this->registry->register($operation); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('delete', $result['/test']); + self::assertArrayNotHasKey('DELETE', $result['/test']); + } + + public function testMultipleOperationsSamePath(): void + { + $get = new Operation(); + $get->id = 'getUser'; + $get->path = '/users/{id}'; + $get->method = 'GET'; + + $put = new Operation(); + $put->id = 'updateUser'; + $put->path = '/users/{id}'; + $put->method = 'PUT'; + + $this->registry->register($get); + $this->registry->register($put); + + $result = $this->registry->getAll(); + + self::assertArrayHasKey('get', $result['/users/{id}']); + self::assertArrayHasKey('put', $result['/users/{id}']); + } +} diff --git a/tests/Unit/Utils/TypeConverterTest.php b/tests/Unit/Utils/TypeConverterTest.php new file mode 100644 index 0000000..4f6befa --- /dev/null +++ b/tests/Unit/Utils/TypeConverterTest.php @@ -0,0 +1,98 @@ +converter = new TypeConverter(); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('primitiveTypesProvider')] + public function testPrimitiveTypes(string $phpType, string $expected): void + { + self::assertSame($expected, $this->converter->toOpenApiType($phpType)); + } + + public static function primitiveTypesProvider(): array + { + return [ + 'int' => ['int', 'integer'], + 'float' => ['float', 'number'], + 'bool' => ['bool', 'boolean'], + 'string' => ['string', 'string'], + 'array' => ['array', 'array'], + 'iterable' => ['iterable', 'array'], + '?int' => ['?int', 'integer'], + '?string' => ['?string', 'string'], + '?bool' => ['?bool', 'boolean'], + '?float' => ['?float', 'number'], + ]; + } + + public function testClassNameReturnsNull(): void + { + self::assertNull($this->converter->toOpenApiType('App\Dto\UserDto')); + } + + public function testUnknownTypeReturnsNull(): void + { + self::assertNull($this->converter->toOpenApiType('mixed')); + } + + public function testVoidReturnsNull(): void + { + self::assertNull($this->converter->toOpenApiType('void')); + } + + // ------------------------------------------------------------------------- + // toOpenApiProperty — class types + // ------------------------------------------------------------------------- + + public function testBackedStringEnumMapsToStringWithEnumValues(): void + { + $result = $this->converter->toOpenApiProperty(Priority::class); + + self::assertSame(['type' => 'string', 'enum' => ['low', 'medium', 'high']], $result); + } + + public function testUuidMapsToStringWithUuidFormat(): void + { + if (!class_exists(Uuid::class)) { + self::markTestSkipped('symfony/uid not installed'); + } + + $result = $this->converter->toOpenApiProperty(Uuid::class); + + self::assertSame(['type' => 'string', 'format' => 'uuid'], $result); + } + + public function testDateTimeImmutableMapsToStringWithDateTimeFormat(): void + { + $result = $this->converter->toOpenApiProperty(\DateTimeImmutable::class); + + self::assertSame(['type' => 'string', 'format' => 'date-time'], $result); + } + + public function testDateTimeInterfaceMapsToStringWithDateTimeFormat(): void + { + $result = $this->converter->toOpenApiProperty(\DateTimeInterface::class); + + self::assertSame(['type' => 'string', 'format' => 'date-time'], $result); + } + + public function testUnknownClassReturnsNull(): void + { + self::assertNull($this->converter->toOpenApiProperty(\stdClass::class)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a075e1e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ + Date: Fri, 20 Feb 2026 13:37:09 +0300 Subject: [PATCH 2/3] [refactor] architectural improvements - Add SecurityParser::SECURITY_PLACEHOLDER constant; remove magic string 'default' - Fix EntityTypeHandler: replace non-standard format with type: integer - Delete dead #[Model] attribute - Locator: pre-filter also matches FQCN form of attributes - Extract PublicPropertyParser: shared property-building logic from ViewParser + ObjectParser - Extract OpenApiSerializer: all serialization logic moved out of registries - ComponentRegistry/OperationRegistry: getAll() now returns raw Model[] (pure storage) - DocumentBuilder: uses OpenApiSerializer; requestToQueryParams moved here - Update + add tests: OpenApiSerializerTest, simplified RegistryTests Co-Authored-By: Claude Sonnet 4.6 --- src/Attribute/Model.php | 15 - src/Builder/DocumentBuilder.php | 73 ++-- src/Locator/Locator.php | 7 +- .../FormTypeHandler/EntityTypeHandler.php | 9 +- src/Parser/ObjectParser.php | 45 +-- src/Parser/PublicPropertyParser.php | 63 ++++ src/Parser/SecurityParser.php | 5 +- src/Parser/ViewParser.php | 47 +-- src/Registry/ComponentRegistry.php | 74 +--- src/Registry/OperationRegistry.php | 106 +----- src/Serializer/OpenApiSerializer.php | 204 +++++++++++ .../AbstractIntegrationTestCase.php | 21 +- tests/Integrational/Parser/FormParserTest.php | 18 +- .../Integrational/Parser/ObjectParserTest.php | 16 +- tests/Integrational/Parser/ViewParserTest.php | 27 +- tests/Unit/Builder/DocumentBuilderTest.php | 7 +- tests/Unit/Registry/ComponentRegistryTest.php | 193 ++--------- tests/Unit/Registry/OperationRegistryTest.php | 168 +++------ .../Unit/Serializer/OpenApiSerializerTest.php | 318 ++++++++++++++++++ 19 files changed, 760 insertions(+), 656 deletions(-) delete mode 100644 src/Attribute/Model.php create mode 100644 src/Parser/PublicPropertyParser.php create mode 100644 src/Serializer/OpenApiSerializer.php create mode 100644 tests/Unit/Serializer/OpenApiSerializerTest.php diff --git a/src/Attribute/Model.php b/src/Attribute/Model.php deleted file mode 100644 index f334625..0000000 --- a/src/Attribute/Model.php +++ /dev/null @@ -1,15 +0,0 @@ -buildPaths($protoData); - $components = $this->buildComponents($protoData); + $protoSchemes = $protoData['components']['securitySchemes'] ?? []; + $firstScheme = !empty($protoSchemes) ? array_key_first($protoSchemes) : null; + + [$paths, $excludedIds] = $this->serializer->serializePaths( + $this->operationRegistry->getAll(), + $firstScheme, + ); + + $components = $this->serializer->serializeComponents( + $this->componentRegistry->getAll(), + $excludedIds, + ); + + $components = $this->mergeProto($components, $protoData); return [ 'openapi' => '3.0.1', @@ -33,54 +45,21 @@ public function build( ]; } - private function buildPaths(array $protoData): array - { - $paths = $this->operationRegistry->getAll(); - $protoSecuritySchemes = $protoData['components']['securitySchemes'] ?? []; - $firstSecurity = !empty($protoSecuritySchemes) ? array_key_first($protoSecuritySchemes) : null; - - foreach ($paths as &$methods) { - foreach ($methods as &$operation) { - // Do NOT use ?? here: it creates a temporary copy, breaking the reference chain. - if (empty($operation['security'])) { - continue; - } - foreach ($operation['security'] as &$security) { - if (isset($security['default'])) { - if (null !== $firstSecurity) { - // Replace placeholder with the first defined scheme - $security[$firstSecurity] = $security['default']; - } - unset($security['default']); - } - } - // Remove security entries that became empty after stripping 'default' - $operation['security'] = array_values(array_filter($operation['security'])); - if (empty($operation['security'])) { - unset($operation['security']); - } - } - } - - return $paths; - } - - private function buildComponents(array $protoData): array + private function mergeProto(array $components, array $protoData): array { - $excludedIds = $this->operationRegistry->getExcludedComponentIds(); - $components = $this->componentRegistry->getAll($excludedIds); + $protoComponents = $protoData['components'] ?? []; - $securitySchemes = $protoData['components']['securitySchemes'] ?? []; - if (!empty($securitySchemes)) { - $components['securitySchemes'] = $securitySchemes; + if (!empty($protoComponents['securitySchemes'])) { + $components['securitySchemes'] = $protoComponents['securitySchemes']; } - $schemas = $protoData['components']['schemas'] ?? []; - $components['schemas'] = array_merge($components['schemas'] ?? [], $schemas); + $components['schemas'] = array_merge( + $components['schemas'] ?? [], + $protoComponents['schemas'] ?? [], + ); - $responses = $protoData['components']['responses'] ?? []; - if (!empty($responses)) { - $components['responses'] = $responses; + if (!empty($protoComponents['responses'])) { + $components['responses'] = $protoComponents['responses']; } return $components; diff --git a/src/Locator/Locator.php b/src/Locator/Locator.php index 61f4e7f..bcb68c7 100644 --- a/src/Locator/Locator.php +++ b/src/Locator/Locator.php @@ -38,10 +38,11 @@ private function getClasses(string $srcPath): iterable } // Pre-filter: only load classes whose source mentions both attributes. - // This avoids triggering class-loading (and potential uncatchable fatal - // compile errors) on files that cannot possibly match. + // Checks both short-name (#[Operation) and FQCN (Attribute\Operation) forms. $content = file_get_contents($file->getPathname()); - if (!str_contains($content, '#[Operation') || !str_contains($content, '#[Route')) { + $hasOperation = str_contains($content, '#[Operation') || str_contains($content, 'Attribute\Operation'); + $hasRoute = str_contains($content, '#[Route') || str_contains($content, 'Attribute\Route'); + if (!$hasOperation || !$hasRoute) { continue; } diff --git a/src/Parser/FormTypeHandler/EntityTypeHandler.php b/src/Parser/FormTypeHandler/EntityTypeHandler.php index c655906..5653346 100644 --- a/src/Parser/FormTypeHandler/EntityTypeHandler.php +++ b/src/Parser/FormTypeHandler/EntityTypeHandler.php @@ -16,16 +16,11 @@ public function supports(string $blockPrefix): bool public function handle(Property $property, FormConfigInterface $config): void { - $entityClass = $config->getOption('class'); - if ($config->getOption('multiple')) { - $property->format = sprintf('[%s id]', $entityClass); $property->type = 'array'; - $subProperty = Property::factory('items', 'string'); - $property->items = $subProperty; + $property->items = Property::factory('items', 'integer'); } else { - $property->type = 'string'; - $property->format = sprintf('%s id', $entityClass); + $property->type = 'integer'; } } } diff --git a/src/Parser/ObjectParser.php b/src/Parser/ObjectParser.php index 9f80df5..f80ea40 100644 --- a/src/Parser/ObjectParser.php +++ b/src/Parser/ObjectParser.php @@ -4,12 +4,8 @@ namespace ChamberOrchestra\OpenApiDocBundle\Parser; -use ChamberOrchestra\OpenApiDocBundle\Describer\DescriberInterface; -use ChamberOrchestra\OpenApiDocBundle\Exception\DescriberException; use ChamberOrchestra\OpenApiDocBundle\Model\Component; use ChamberOrchestra\OpenApiDocBundle\Model\Model; -use ChamberOrchestra\OpenApiDocBundle\Model\Property; -use ChamberOrchestra\OpenApiDocBundle\Utils\TypeConverter; use ChamberOrchestra\ViewBundle\View\ViewInterface; use ReflectionClass; use ReflectionNamedType; @@ -20,8 +16,7 @@ class ObjectParser implements ComponentParserInterface { public function __construct( private PropertyParser $parser, - private TypeConverter $typeConverter, - private DescriberInterface $describer, + private PublicPropertyParser $publicPropertyParser, ) { } @@ -37,42 +32,16 @@ public function parse(Model $model, object $reflection): Model { /* @var Component $model */ /* @var ReflectionClass $reflection */ - $model->id = $reflection->getShortName(); + $model->id = $reflection->getShortName(); $parameters = []; - $required = []; + $required = []; foreach ($reflection->getProperties() as $property) { $reflectionType = $property->getType(); - $typeName = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $typeName = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; - if (null === $typeName) { - $parameter = Property::factory($property->getName(), 'string'); - } else { - $openApiType = $this->typeConverter->toOpenApiType($typeName); - if (null === $openApiType) { - if ($openApiProperty = $this->typeConverter->toOpenApiProperty($typeName)) { - $parameter = Property::factory($property->getName(), $openApiProperty['type']); - if (isset($openApiProperty['format'])) { - $parameter->format = $openApiProperty['format']; - } - if (!empty($openApiProperty['enum'])) { - $parameter->enum = $openApiProperty['enum']; - } - } else { - try { - $child = $this->describer->describe($typeName); - $parameter = Property::factory($property->getName(), 'object'); - $parameter->ref = $child; - } catch (DescriberException $e) { - $parameter = Property::factory($property->getName(), 'string'); - } - } - } else { - $parameter = Property::factory($property->getName(), $openApiType); - } - } - - $parameter = $this->parser->parse($parameter, $property); + $parameter = $this->publicPropertyParser->build($property->getName(), $typeName, $model); + $parameter = $this->parser->parse($parameter, $property); $parameters[] = $parameter; $isRequired = ($reflectionType instanceof ReflectionNamedType && !$reflectionType->allowsNull()) @@ -83,7 +52,7 @@ public function parse(Model $model, object $reflection): Model } $model->properties = $parameters; - $model->required = $required; + $model->required = $required; return $model; } diff --git a/src/Parser/PublicPropertyParser.php b/src/Parser/PublicPropertyParser.php new file mode 100644 index 0000000..b77fb4d --- /dev/null +++ b/src/Parser/PublicPropertyParser.php @@ -0,0 +1,63 @@ +typeConverter->toOpenApiType($typeName)) { + return Property::factory($name, $openApiType); + } + + if ($openApiProperty = $this->typeConverter->toOpenApiProperty($typeName)) { + $property = Property::factory($name, $openApiProperty['type']); + $property->format = $openApiProperty['format'] ?? null; + $property->enum = $openApiProperty['enum'] ?? []; + + return $property; + } + + try { + $child = $this->describer->describe($typeName); + $child->parent = $parent; + $property = Property::factory($name, 'object'); + $property->ref = $child; + + return $property; + } catch (\Throwable) { + return Property::factory($name, 'string'); + } + } +} diff --git a/src/Parser/SecurityParser.php b/src/Parser/SecurityParser.php index dd02304..f5905f5 100644 --- a/src/Parser/SecurityParser.php +++ b/src/Parser/SecurityParser.php @@ -10,6 +10,9 @@ class SecurityParser implements OperationParserInterface { + /** Placeholder replaced by DocumentBuilder with the first proto.yaml securityScheme name. */ + public const SECURITY_PLACEHOLDER = 'default'; + public function supports(object $attribute): bool { return $attribute instanceof ReflectionAttribute @@ -19,7 +22,7 @@ public function supports(object $attribute): bool public function parse(Model $model, object $attribute): Model { if (empty($model->security)) { - $model->security['default'] = []; + $model->security[self::SECURITY_PLACEHOLDER] = []; } return $model; diff --git a/src/Parser/ViewParser.php b/src/Parser/ViewParser.php index ac7c0eb..848019a 100644 --- a/src/Parser/ViewParser.php +++ b/src/Parser/ViewParser.php @@ -14,7 +14,6 @@ use ChamberOrchestra\OpenApiDocBundle\Model\Property; use ChamberOrchestra\OpenApiDocBundle\Utils\TypeConverter; use ReflectionClass; -use ReflectionException; use ReflectionNamedType; use ReflectionProperty; use Symfony\Component\HttpFoundation\Response; @@ -26,6 +25,7 @@ public function __construct( private DescriberInterface $describer, private PropertyParser $parser, private TypeConverter $typeConverter, + private PublicPropertyParser $publicPropertyParser, ) { } @@ -98,55 +98,22 @@ public function parse(Model $model, object $item): Model private function buildProperty(ReflectionProperty $property, Component $model): Property { - $propertyName = $property->getName(); + $propertyName = $property->getName(); $reflectionType = $property->getType(); - - if (!$reflectionType instanceof ReflectionNamedType) { - return Property::factory($propertyName, 'string'); - } - - $phpTypeName = $reflectionType->getName(); - $openApiType = $this->typeConverter->toOpenApiType($phpTypeName); - - // Primitive type (int, string, bool, float, array, iterable) - if (null !== $openApiType) { - $parameter = Property::factory($propertyName, $openApiType); - if ('array' === $openApiType) { - $this->resolveArrayItems($parameter, $property); - } - - return $parameter; - } + $phpTypeName = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; // IterableView property — always renders as array; items resolved from #[Type] - if ($this->isIterableViewType($phpTypeName)) { + if (null !== $phpTypeName && $this->isIterableViewType($phpTypeName)) { $parameter = Property::factory($propertyName, 'array'); $this->resolveArrayItems($parameter, $property); return $parameter; } - // BackedEnum or well-known type (Uuid, DateTime) — map to primitive - if ($openApiProperty = $this->typeConverter->toOpenApiProperty($phpTypeName)) { - $parameter = Property::factory($propertyName, $openApiProperty['type']); - if (isset($openApiProperty['format'])) { - $parameter->format = $openApiProperty['format']; - } - if (!empty($openApiProperty['enum'])) { - $parameter->enum = $openApiProperty['enum']; - } - - return $parameter; - } + $parameter = $this->publicPropertyParser->build($propertyName, $phpTypeName, $model); - // Other class — describe as a component $ref - try { - $child = $this->describer->describe($phpTypeName); - $child->parent = $model; - $parameter = Property::factory($propertyName, 'object'); - $parameter->ref = $child; - } catch (ReflectionException $e) { - $parameter = Property::factory($propertyName, 'string'); + if ('array' === $parameter->type && null === $parameter->items) { + $this->resolveArrayItems($parameter, $property); } return $parameter; diff --git a/src/Registry/ComponentRegistry.php b/src/Registry/ComponentRegistry.php index c2491e9..64ccfee 100644 --- a/src/Registry/ComponentRegistry.php +++ b/src/Registry/ComponentRegistry.php @@ -5,80 +5,12 @@ namespace ChamberOrchestra\OpenApiDocBundle\Registry; use ChamberOrchestra\OpenApiDocBundle\Model\Component; -use ChamberOrchestra\OpenApiDocBundle\Model\Property; -use function in_array; class ComponentRegistry extends Registry { - public function getAll(array $excludedIds = []): array + /** @return Component[] */ + public function getAll(): array { - $data = []; - $components = $this->models; - - /* @var Component $component */ - /* @var Property $property */ - foreach ($components as $component) { - if (in_array($component->id, $excludedIds, true)) { - continue; - } - if ('array' === $component->type) { - $componentData = ['type' => 'array']; - if (null !== $component->items) { - $componentData['items'] = $this->propertyToArray($component->items); - } - } else { - $componentData = ['properties' => []]; - $properties = $component->properties; - foreach ($properties as $property) { - $componentData['properties'][$property->name] = $this->propertyToArray($property); - } - if (!empty($required = $component->required)) { - $componentData['required'] = $required; - } - } - $data[$component->id] = $componentData; - } - - return [ - 'schemas' => $data, - ]; - } - - private function propertyToArray(Property $property): array - { - $data = []; - - if (null !== $ref = $property->ref) { - $data['$ref'] = '#/components/schemas/'.$ref->id; - } else { - $data['type'] = $property->type; - } - - if (null !== $format = $property->format) { - $data['format'] = $format; - } - - if (!empty($items = $property->items)) { - $data['items'] = $this->propertyToArray($items); - } elseif (($data['type'] ?? null) === 'array') { - $data['items'] = ['type' => 'object']; - } - - if (!empty($enum = $property->enum)) { - $data['enum'] = $enum; - } - - if (!empty($property->properties)) { - foreach ($property->properties as $subProperty) { - $data['properties'][$subProperty->name] = $this->propertyToArray($subProperty); - } - if (!empty($property->requiredProperties)) { - $data['required'] = $property->requiredProperties; - } - } - - $data += $property->attributes; - - return $data; + return array_values($this->models); } } diff --git a/src/Registry/OperationRegistry.php b/src/Registry/OperationRegistry.php index 3d790a3..d26f365 100644 --- a/src/Registry/OperationRegistry.php +++ b/src/Registry/OperationRegistry.php @@ -4,115 +4,13 @@ namespace ChamberOrchestra\OpenApiDocBundle\Registry; -use ChamberOrchestra\OpenApiDocBundle\Model\Component; use ChamberOrchestra\OpenApiDocBundle\Model\Operation; class OperationRegistry extends Registry { - /** Component IDs expanded as query params (excluded from schemas output). Populated during getAll(). */ - private array $excludedComponentIds = []; - - public function getExcludedComponentIds(): array - { - return $this->excludedComponentIds; - } - + /** @return Operation[] */ public function getAll(): array { - $this->excludedComponentIds = []; - $operations = $this->models; - $data = []; - - /* @var Operation $operation */ - /* @var string|Component $response */ - foreach ($operations as $operation) { - if ('' === $operation->path || '' === $operation->method) { - throw new \LogicException(sprintf( - 'Operation "%s" is missing path or method.', - $operation->id ?? '(unknown)', - )); - } - - $pathData = []; - if (null !== $description = $operation->description) { - $pathData['description'] = $description; - } - $pathData['operationId'] = $operation->id; - - if (!empty($operation->parameters)) { - $pathData['parameters'] = $operation->parameters; - } - - $responses = []; - foreach ($operation->responses as $status => $response) { - if ($response instanceof Component) { - // OpenAPI requires status codes to be string keys ("200", not 200) - $httpStatus = (string)($response->status ?? 200); - $content = $response->headers['Content-Type'] ?? 'application/json'; - $responses[$httpStatus]['description'] = $response->id ?? 'Successful response'; - $responses[$httpStatus]['content'][$content]['schema']['$ref'] = - '#/components/schemas/'.$response->id; - } else { - $responses[(string)$status]['$ref'] = '#/components/responses/'.$response; - } - } - - if (null !== $operation->request) { - if (in_array(strtoupper($operation->method), ['GET', 'DELETE', 'HEAD'], true)) { - // GET/DELETE have no request body — expand form fields as query parameters - $queryParams = $this->requestToQueryParams($operation->request); - if (!empty($queryParams)) { - $pathData['parameters'] = array_merge($pathData['parameters'] ?? [], $queryParams); - } - } else { - $request = []; - $request['content']['application/json']['schema']['$ref'] = - '#/components/schemas/'.$operation->request->id; - $pathData['requestBody'] = $request; - } - } - - if (!empty($security = $operation->security)) { - $pathData['security'] = [$security]; - } - - $pathData['responses'] = $responses; - $data[$operation->path][strtolower($operation->method)] = $pathData; - } - - return $data; - } - - private function requestToQueryParams(Component $component): array - { - $this->excludedComponentIds[] = $component->id; - $params = []; - $required = $component->required ?? []; - - foreach ($component->properties as $property) { - $schema = []; - if (null !== $property->ref) { - $schema['$ref'] = '#/components/schemas/'.$property->ref->id; - } else { - $schema['type'] = $property->type; - if (!empty($property->enum)) { - $schema['enum'] = $property->enum; - } - if (!empty($property->format)) { - $schema['format'] = $property->format; - } - } - - $param = [ - 'name' => $property->name, - 'in' => 'query', - 'required' => in_array($property->name, $required, true), - 'schema' => $schema, - ]; - - $params[] = $param; - } - - return $params; + return array_values($this->models); } } diff --git a/src/Serializer/OpenApiSerializer.php b/src/Serializer/OpenApiSerializer.php new file mode 100644 index 0000000..3d9cc38 --- /dev/null +++ b/src/Serializer/OpenApiSerializer.php @@ -0,0 +1,204 @@ +path || '' === $operation->method) { + throw new \LogicException(sprintf( + 'Operation "%s" is missing path or method.', + $operation->id ?? '(unknown)', + )); + } + + $pathData = []; + if (null !== $operation->description) { + $pathData['description'] = $operation->description; + } + $pathData['operationId'] = $operation->id; + + if (!empty($operation->parameters)) { + $pathData['parameters'] = $operation->parameters; + } + + $responses = []; + foreach ($operation->responses as $status => $response) { + if ($response instanceof Component) { + $httpStatus = (string) ($response->status ?? 200); + $content = $response->headers['Content-Type'] ?? 'application/json'; + $responses[$httpStatus]['description'] = $response->id ?? 'Successful response'; + $responses[$httpStatus]['content'][$content]['schema']['$ref'] = '#/components/schemas/'.$response->id; + } else { + $responses[(string) $status]['$ref'] = '#/components/responses/'.$response; + } + } + + if (null !== $operation->request) { + if (in_array(strtoupper($operation->method), ['GET', 'DELETE', 'HEAD'], true)) { + [$queryParams, $excludedId] = $this->requestToQueryParams($operation->request); + $excludedIds[] = $excludedId; + if (!empty($queryParams)) { + $pathData['parameters'] = array_merge($pathData['parameters'] ?? [], $queryParams); + } + } else { + $pathData['requestBody']['content']['application/json']['schema']['$ref'] = + '#/components/schemas/'.$operation->request->id; + } + } + + if (!empty($security = $operation->security)) { + $resolved = $security; + if (isset($resolved[SecurityParser::SECURITY_PLACEHOLDER])) { + if (null !== $firstSecurityScheme) { + $resolved[$firstSecurityScheme] = $resolved[SecurityParser::SECURITY_PLACEHOLDER]; + } + unset($resolved[SecurityParser::SECURITY_PLACEHOLDER]); + } + if (!empty($resolved)) { + $pathData['security'] = [$resolved]; + } + } + + $pathData['responses'] = $responses; + $paths[$operation->path][strtolower($operation->method)] = $pathData; + } + + return [$paths, $excludedIds]; + } + + /** + * Serialize components into an OpenAPI `components.schemas` structure. + * + * @param Component[] $components + * @param string[] $excludedIds Component IDs to omit (e.g. GET-request form schemas). + * @return array{schemas: array} + */ + public function serializeComponents(array $components, array $excludedIds = []): array + { + $data = []; + + foreach ($components as $component) { + if (in_array($component->id, $excludedIds, true)) { + continue; + } + + if ('array' === $component->type) { + $componentData = ['type' => 'array']; + if (null !== $component->items) { + $componentData['items'] = $this->propertyToArray($component->items); + } + } else { + $componentData = ['properties' => []]; + foreach ($component->properties as $property) { + $componentData['properties'][$property->name] = $this->propertyToArray($property); + } + if (!empty($component->required)) { + $componentData['required'] = $component->required; + } + } + + $data[$component->id] = $componentData; + } + + return ['schemas' => $data]; + } + + public function propertyToArray(Property $property): array + { + $data = []; + + if (null !== $ref = $property->ref) { + $data['$ref'] = '#/components/schemas/'.$ref->id; + } else { + $data['type'] = $property->type; + } + + if (null !== $property->format) { + $data['format'] = $property->format; + } + + if (!empty($property->items)) { + $data['items'] = $this->propertyToArray($property->items); + } elseif (($data['type'] ?? null) === 'array') { + $data['items'] = ['type' => 'object']; + } + + if (!empty($property->enum)) { + $data['enum'] = $property->enum; + } + + if (!empty($property->properties)) { + foreach ($property->properties as $subProperty) { + $data['properties'][$subProperty->name] = $this->propertyToArray($subProperty); + } + if (!empty($property->requiredProperties)) { + $data['required'] = $property->requiredProperties; + } + } + + $data += $property->attributes; + + return $data; + } + + /** + * Convert a form Component's properties into OpenAPI query parameters. + * + * @return array{0: array, 1: string} [queryParams, excludedComponentId] + */ + private function requestToQueryParams(Component $component): array + { + $params = []; + $required = $component->required ?? []; + + foreach ($component->properties as $property) { + $schema = []; + if (null !== $property->ref) { + $schema['$ref'] = '#/components/schemas/'.$property->ref->id; + } else { + $schema['type'] = $property->type; + if (!empty($property->enum)) { + $schema['enum'] = $property->enum; + } + if (!empty($property->format)) { + $schema['format'] = $property->format; + } + } + + $params[] = [ + 'name' => $property->name, + 'in' => 'query', + 'required' => in_array($property->name, $required, true), + 'schema' => $schema, + ]; + } + + return [$params, $component->id]; + } +} diff --git a/tests/Integrational/AbstractIntegrationTestCase.php b/tests/Integrational/AbstractIntegrationTestCase.php index 40b6060..375853c 100644 --- a/tests/Integrational/AbstractIntegrationTestCase.php +++ b/tests/Integrational/AbstractIntegrationTestCase.php @@ -8,8 +8,10 @@ use ChamberOrchestra\OpenApiDocBundle\Describer\ComponentDescriber; use ChamberOrchestra\OpenApiDocBundle\Parser\ObjectParser; use ChamberOrchestra\OpenApiDocBundle\Parser\PropertyParser; +use ChamberOrchestra\OpenApiDocBundle\Parser\PublicPropertyParser; use ChamberOrchestra\OpenApiDocBundle\Parser\ViewParser; use ChamberOrchestra\OpenApiDocBundle\Registry\ComponentRegistry; +use ChamberOrchestra\OpenApiDocBundle\Serializer\OpenApiSerializer; use ChamberOrchestra\OpenApiDocBundle\Utils\TypeConverter; use PHPUnit\Framework\TestCase; @@ -18,10 +20,12 @@ abstract class AbstractIntegrationTestCase extends TestCase protected ComponentRegistry $registry; protected ComponentDescriber $describer; protected ArrayObject $parsersContainer; + protected OpenApiSerializer $serializer; protected function setUp(): void { - $this->registry = new ComponentRegistry(); + $this->registry = new ComponentRegistry(); + $this->serializer = new OpenApiSerializer(); // Use ArrayObject so we can break the circular dependency: // ComponentDescriber needs parsers, but parsers need ComponentDescriber. @@ -31,13 +35,20 @@ protected function setUp(): void $this->describer = new ComponentDescriber($this->parsersContainer, $this->registry); - $propertyParser = new PropertyParser(); - $typeConverter = new TypeConverter(); + $propertyParser = new PropertyParser(); + $typeConverter = new TypeConverter(); + $publicPropertyParser = new PublicPropertyParser($typeConverter, $this->describer); - $viewParser = new ViewParser($this->describer, $propertyParser, $typeConverter); - $objectParser = new ObjectParser($propertyParser, $typeConverter, $this->describer); + $viewParser = new ViewParser($this->describer, $propertyParser, $typeConverter, $publicPropertyParser); + $objectParser = new ObjectParser($propertyParser, $publicPropertyParser); $this->parsersContainer->append($viewParser); $this->parsersContainer->append($objectParser); } + + /** Serialize all registered components to OpenAPI schemas array. */ + protected function schemas(): array + { + return $this->serializer->serializeComponents($this->registry->getAll())['schemas']; + } } diff --git a/tests/Integrational/Parser/FormParserTest.php b/tests/Integrational/Parser/FormParserTest.php index c2748f0..d5bbe61 100644 --- a/tests/Integrational/Parser/FormParserTest.php +++ b/tests/Integrational/Parser/FormParserTest.php @@ -203,18 +203,18 @@ public function testNestedFormTypeRegistersChildComponent(): void { $this->describer->describe(NestedFormType::class); - $result = $this->registry->getAll(); + $result = $this->schemas(); - self::assertArrayHasKey('NestedFormType', $result['schemas']); - self::assertArrayHasKey('SimpleFormType', $result['schemas']); + self::assertArrayHasKey('NestedFormType', $result); + self::assertArrayHasKey('SimpleFormType', $result); } public function testNestedFormTypeOutputRefSchema(): void { $this->describer->describe(NestedFormType::class); - $result = $this->registry->getAll(); - $schema = $result['schemas']['NestedFormType']; + $result = $this->schemas(); + $schema = $result['NestedFormType']; self::assertSame( '#/components/schemas/SimpleFormType', @@ -250,9 +250,9 @@ public function testCollectionFormTypeRegistersEntryTypeComponent(): void { $this->describer->describe(CollectionFormType::class); - $result = $this->registry->getAll(); + $result = $this->schemas(); - self::assertArrayHasKey('SimpleFormType', $result['schemas']); + self::assertArrayHasKey('SimpleFormType', $result); } // ------------------------------------------------------------------------- @@ -335,8 +335,8 @@ public function testRecursiveFormTypeChildrenRefPointsToItself(): void { $this->describer->describe(RecursiveFormType::class); - $result = $this->registry->getAll(); - $schema = $result['schemas']['RecursiveFormType']; + $result = $this->schemas(); + $schema = $result['RecursiveFormType']; self::assertSame( '#/components/schemas/RecursiveFormType', diff --git a/tests/Integrational/Parser/ObjectParserTest.php b/tests/Integrational/Parser/ObjectParserTest.php index 4531c53..8b58af2 100644 --- a/tests/Integrational/Parser/ObjectParserTest.php +++ b/tests/Integrational/Parser/ObjectParserTest.php @@ -35,17 +35,14 @@ public function testSimpleDtoIsRegisteredInRegistry(): void { $this->describer->describe(SimpleDto::class); - $result = $this->registry->getAll(); - - self::assertArrayHasKey('SimpleDto', $result['schemas']); + self::assertArrayHasKey('SimpleDto', $this->schemas()); } public function testSimpleDtoOutputSchema(): void { $this->describer->describe(SimpleDto::class); - $result = $this->registry->getAll(); - $schema = $result['schemas']['SimpleDto']; + $schema = $this->schemas()['SimpleDto']; self::assertSame('string', $schema['properties']['name']['type']); self::assertSame('integer', $schema['properties']['count']['type']); @@ -75,18 +72,17 @@ public function testNestedDtoRegistersChildComponent(): void { $this->describer->describe(NestedDto::class); - $result = $this->registry->getAll(); + $schemas = $this->schemas(); - self::assertArrayHasKey('NestedDto', $result['schemas']); - self::assertArrayHasKey('SimpleDto', $result['schemas']); + self::assertArrayHasKey('NestedDto', $schemas); + self::assertArrayHasKey('SimpleDto', $schemas); } public function testNestedDtoOutputRefSchema(): void { $this->describer->describe(NestedDto::class); - $result = $this->registry->getAll(); - $nestedSchema = $result['schemas']['NestedDto']; + $nestedSchema = $this->schemas()['NestedDto']; self::assertSame( '#/components/schemas/SimpleDto', diff --git a/tests/Integrational/Parser/ViewParserTest.php b/tests/Integrational/Parser/ViewParserTest.php index c300d30..6814fbf 100644 --- a/tests/Integrational/Parser/ViewParserTest.php +++ b/tests/Integrational/Parser/ViewParserTest.php @@ -17,7 +17,7 @@ public function testSimpleViewProducesObjectSchema(): void $component = $this->describer->describe(SimpleView::class); self::assertSame('SimpleView', $component->id); - self::assertNull($component->type); // plain object, not 'array' + self::assertNull($component->type); $properties = $this->indexByName($component->properties); @@ -38,9 +38,7 @@ public function testSimpleViewIsRegisteredInRegistry(): void { $this->describer->describe(SimpleView::class); - $result = $this->registry->getAll(); - - self::assertArrayHasKey('SimpleView', $result['schemas']); + self::assertArrayHasKey('SimpleView', $this->schemas()); } public function testNestedViewCreatesRefToChild(): void @@ -65,18 +63,17 @@ public function testNestedViewRegistersChildComponent(): void { $this->describer->describe(NestedView::class); - $result = $this->registry->getAll(); + $schemas = $this->schemas(); - self::assertArrayHasKey('NestedView', $result['schemas']); - self::assertArrayHasKey('SimpleView', $result['schemas']); + self::assertArrayHasKey('NestedView', $schemas); + self::assertArrayHasKey('SimpleView', $schemas); } public function testNestedViewRefInOutput(): void { $this->describer->describe(NestedView::class); - $result = $this->registry->getAll(); - $nestedSchema = $result['schemas']['NestedView']; + $nestedSchema = $this->schemas()['NestedView']; self::assertSame( '#/components/schemas/SimpleView', @@ -111,8 +108,7 @@ public function testIterableContainerViewOutputSchema(): void { $this->describer->describe(IterableContainerView::class); - $result = $this->registry->getAll(); - $schema = $result['schemas']['IterableContainerView']; + $schema = $this->schemas()['IterableContainerView']; self::assertSame('string', $schema['properties']['title']['type']); @@ -129,10 +125,6 @@ public function testDuplicateDescribeReturnsSameComponent(): void self::assertSame($first, $second); } - // ------------------------------------------------------------------------- - // RecursiveView — self-referential tree structure - // ------------------------------------------------------------------------- - public function testRecursiveViewDoesNotCauseInfiniteLoop(): void { $component = $this->describer->describe(RecursiveView::class); @@ -144,8 +136,7 @@ public function testRecursiveViewChildrenRefPointsToItself(): void { $this->describer->describe(RecursiveView::class); - $result = $this->registry->getAll(); - $schema = $result['schemas']['RecursiveView']; + $schema = $this->schemas()['RecursiveView']; self::assertSame( '#/components/schemas/RecursiveView', @@ -160,7 +151,7 @@ public function testRecursiveViewRegisteredOnce(): void self::assertSame($first, $second); self::assertCount(1, array_filter( - array_keys($this->registry->getAll()['schemas']), + array_keys($this->schemas()), fn($k) => $k === 'RecursiveView' )); } diff --git a/tests/Unit/Builder/DocumentBuilderTest.php b/tests/Unit/Builder/DocumentBuilderTest.php index 0d953c8..71bae84 100644 --- a/tests/Unit/Builder/DocumentBuilderTest.php +++ b/tests/Unit/Builder/DocumentBuilderTest.php @@ -10,6 +10,7 @@ use ChamberOrchestra\OpenApiDocBundle\Model\Property; use ChamberOrchestra\OpenApiDocBundle\Registry\ComponentRegistry; use ChamberOrchestra\OpenApiDocBundle\Registry\OperationRegistry; +use ChamberOrchestra\OpenApiDocBundle\Serializer\OpenApiSerializer; use PHPUnit\Framework\TestCase; class DocumentBuilderTest extends TestCase @@ -22,7 +23,7 @@ protected function setUp(): void { $this->operationRegistry = new OperationRegistry(); $this->componentRegistry = new ComponentRegistry(); - $this->builder = new DocumentBuilder($this->operationRegistry, $this->componentRegistry); + $this->builder = new DocumentBuilder($this->operationRegistry, $this->componentRegistry, new OpenApiSerializer()); } public function testBuildReturnsOpenApiStructure(): void @@ -120,7 +121,7 @@ public function testBuildResolvesDefaultSecurityToFirstScheme(): void $operation->id = 'securedOp'; $operation->path = '/secured'; $operation->method = 'GET'; - $operation->security = ['default' => []]; + $operation->security = [\ChamberOrchestra\OpenApiDocBundle\Parser\SecurityParser::SECURITY_PLACEHOLDER => []]; $this->operationRegistry->register($operation); $protoData = [ @@ -144,7 +145,7 @@ public function testBuildStripsDefaultSecurityWhenNoProtoSchemes(): void $operation->id = 'securedOp'; $operation->path = '/secured'; $operation->method = 'GET'; - $operation->security = ['default' => []]; + $operation->security = [\ChamberOrchestra\OpenApiDocBundle\Parser\SecurityParser::SECURITY_PLACEHOLDER => []]; $this->operationRegistry->register($operation); $result = $this->builder->build(); diff --git a/tests/Unit/Registry/ComponentRegistryTest.php b/tests/Unit/Registry/ComponentRegistryTest.php index bb2e96d..ccebc4f 100644 --- a/tests/Unit/Registry/ComponentRegistryTest.php +++ b/tests/Unit/Registry/ComponentRegistryTest.php @@ -20,199 +20,68 @@ protected function setUp(): void public function testEmptyRegistry(): void { - self::assertSame(['schemas' => []], $this->registry->getAll()); + self::assertSame([], $this->registry->getAll()); } - public function testSimpleComponent(): void + public function testRegisterAndRetrieve(): void { - $component = new Component(); - $component->id = 'SimpleModel'; + $component = new Component(); + $component->id = 'UserView'; $component->properties = [Property::factory('name', 'string')]; $this->registry->register($component); - $result = $this->registry->getAll(); + $all = $this->registry->getAll(); - self::assertArrayHasKey('SimpleModel', $result['schemas']); - self::assertSame('string', $result['schemas']['SimpleModel']['properties']['name']['type']); - } - - public function testComponentWithMultipleProperties(): void - { - $component = new Component(); - $component->id = 'UserModel'; - $component->properties = [ - Property::factory('name', 'string'), - Property::factory('age', 'integer'), - Property::factory('active', 'boolean'), - ]; - - $this->registry->register($component); - - $result = $this->registry->getAll(); - $schema = $result['schemas']['UserModel']; - - self::assertSame('string', $schema['properties']['name']['type']); - self::assertSame('integer', $schema['properties']['age']['type']); - self::assertSame('boolean', $schema['properties']['active']['type']); - } - - public function testComponentWithRequired(): void - { - $component = new Component(); - $component->id = 'RequiredModel'; - $component->properties = [Property::factory('name', 'string')]; - $component->required = ['name']; - - $this->registry->register($component); - - $result = $this->registry->getAll(); - - self::assertSame(['name'], $result['schemas']['RequiredModel']['required']); - } - - public function testComponentWithoutRequiredOmitsField(): void - { - $component = new Component(); - $component->id = 'OptionalModel'; - $component->properties = [Property::factory('name', 'string')]; - // $component->required stays [] - - $this->registry->register($component); - - $result = $this->registry->getAll(); - - self::assertArrayNotHasKey('required', $result['schemas']['OptionalModel']); - } - - public function testPropertyWithRef(): void - { - $refComponent = new Component(); - $refComponent->id = 'RefModel'; - - $component = new Component(); - $component->id = 'ParentModel'; - $refProperty = Property::factory('child', 'object'); - $refProperty->ref = $refComponent; - $component->properties = [$refProperty]; - - $this->registry->register($component); - - $result = $this->registry->getAll(); - $childData = $result['schemas']['ParentModel']['properties']['child']; - - self::assertArrayHasKey('$ref', $childData); - self::assertSame('#/components/schemas/RefModel', $childData['$ref']); - self::assertArrayNotHasKey('type', $childData); - } - - public function testArrayTypeComponent(): void - { - $itemRef = new Component(); - $itemRef->id = 'ItemModel'; - - $itemsProperty = Property::factory('items'); - $itemsProperty->ref = $itemRef; - - $component = new Component(); - $component->id = 'MyList'; - $component->type = 'array'; - $component->items = $itemsProperty; - - $this->registry->register($component); - - $result = $this->registry->getAll(); - $schema = $result['schemas']['MyList']; - - self::assertSame('array', $schema['type']); - self::assertSame('#/components/schemas/ItemModel', $schema['items']['$ref']); + self::assertCount(1, $all); + self::assertSame($component, $all[0]); } public function testDuplicateRegistrationIsIgnored(): void { - $first = new Component(); - $first->id = 'Duplicate'; + $first = new Component(); + $first->id = 'Same'; $first->properties = [Property::factory('original', 'string')]; - $second = new Component(); - $second->id = 'Duplicate'; + $second = new Component(); + $second->id = 'Same'; $second->properties = [Property::factory('override', 'integer')]; $this->registry->register($first); $this->registry->register($second); - $result = $this->registry->getAll(); - - self::assertArrayHasKey('original', $result['schemas']['Duplicate']['properties']); - self::assertArrayNotHasKey('override', $result['schemas']['Duplicate']['properties']); - } - - public function testPropertyWithFormat(): void - { - $property = Property::factory('email', 'string', 'email'); - - $component = new Component(); - $component->id = 'FormModel'; - $component->properties = [$property]; - - $this->registry->register($component); - - $result = $this->registry->getAll(); + $all = $this->registry->getAll(); - self::assertSame('email', $result['schemas']['FormModel']['properties']['email']['format']); + self::assertCount(1, $all); + self::assertSame('original', $all[0]->properties[0]->name); } - public function testPropertyWithEnum(): void - { - $property = Property::factory('status', 'string'); - $property->enum = ['active', 'inactive']; - - $component = new Component(); - $component->id = 'StatusModel'; - $component->properties = [$property]; - - $this->registry->register($component); - - $result = $this->registry->getAll(); - - self::assertSame(['active', 'inactive'], $result['schemas']['StatusModel']['properties']['status']['enum']); - } - - public function testPropertyWithExtraAttributes(): void + public function testMultipleComponents(): void { - $property = Property::factory('username', 'string'); - $property->attributes = ['minLength' => 3, 'maxLength' => 50, 'pattern' => '^[a-z]+$']; + $a = new Component(); + $a->id = 'ModelA'; - $component = new Component(); - $component->id = 'UsernameModel'; - $component->properties = [$property]; + $b = new Component(); + $b->id = 'ModelB'; - $this->registry->register($component); + $this->registry->register($a); + $this->registry->register($b); - $result = $this->registry->getAll(); - $schema = $result['schemas']['UsernameModel']['properties']['username']; - - self::assertSame(3, $schema['minLength']); - self::assertSame(50, $schema['maxLength']); - self::assertSame('^[a-z]+$', $schema['pattern']); + self::assertCount(2, $this->registry->getAll()); } - public function testMultipleComponents(): void + public function testRegisterReturnsSameModelOnDuplicate(): void { - $c1 = new Component(); - $c1->id = 'ModelA'; - $c1->properties = [Property::factory('x', 'string')]; - - $c2 = new Component(); - $c2->id = 'ModelB'; - $c2->properties = [Property::factory('y', 'integer')]; + $component = new Component(); + $component->id = 'Comp'; - $this->registry->register($c1); - $this->registry->register($c2); + $returned = $this->registry->register($component); + self::assertSame($component, $returned); - $result = $this->registry->getAll(); + $duplicate = new Component(); + $duplicate->id = 'Comp'; - self::assertArrayHasKey('ModelA', $result['schemas']); - self::assertArrayHasKey('ModelB', $result['schemas']); + $returned2 = $this->registry->register($duplicate); + self::assertSame($component, $returned2); } } diff --git a/tests/Unit/Registry/OperationRegistryTest.php b/tests/Unit/Registry/OperationRegistryTest.php index 70cdaa4..0f36243 100644 --- a/tests/Unit/Registry/OperationRegistryTest.php +++ b/tests/Unit/Registry/OperationRegistryTest.php @@ -23,154 +23,76 @@ public function testEmptyRegistry(): void self::assertSame([], $this->registry->getAll()); } - public function testSimpleOperation(): void + public function testRegisterAndRetrieve(): void { - $operation = new Operation(); - $operation->id = 'getUser'; - $operation->path = '/users/{id}'; + $operation = new Operation(); + $operation->id = 'getUser'; + $operation->path = '/users/{id}'; $operation->method = 'GET'; - $operation->description = 'Get a user'; $this->registry->register($operation); - $result = $this->registry->getAll(); + $all = $this->registry->getAll(); - self::assertArrayHasKey('/users/{id}', $result); - self::assertArrayHasKey('get', $result['/users/{id}']); - - $op = $result['/users/{id}']['get']; - - self::assertSame('getUser', $op['operationId']); - self::assertSame('Get a user', $op['description']); - } - - public function testOperationWithoutDescriptionOmitsField(): void - { - $operation = new Operation(); - $operation->id = 'listItems'; - $operation->path = '/items'; - $operation->method = 'GET'; - $operation->description = null; - - $this->registry->register($operation); - - $result = $this->registry->getAll(); - $op = $result['/items']['get']; - - self::assertArrayNotHasKey('description', $op); + self::assertCount(1, $all); + self::assertSame($operation, $all[0]); } - public function testOperationWithComponentResponse(): void + public function testDuplicateRegistrationIsIgnored(): void { - $response = new Component(); - $response->id = 'UserView'; - $response->status = 200; - $response->headers = ['Content-Type' => 'application/json']; - - $operation = new Operation(); - $operation->id = 'getUser'; - $operation->path = '/users'; - $operation->method = 'GET'; - $operation->responses = [200 => $response]; - - $this->registry->register($operation); - - $result = $this->registry->getAll(); - $responses = $result['/users']['get']['responses']; - - self::assertArrayHasKey(200, $responses); - self::assertSame('#/components/schemas/UserView', $responses[200]['content']['application/json']['schema']['$ref']); - } + $first = new Operation(); + $first->id = 'op'; + $first->path = '/a'; + $first->method = 'GET'; - public function testOperationWithStringResponse(): void - { - $operation = new Operation(); - $operation->id = 'createUser'; - $operation->path = '/users'; - $operation->method = 'POST'; - $operation->responses = ['404' => 'NotFound']; + $second = new Operation(); + $second->id = 'op'; + $second->path = '/b'; + $second->method = 'POST'; - $this->registry->register($operation); + $this->registry->register($first); + $this->registry->register($second); - $result = $this->registry->getAll(); - $responses = $result['/users']['post']['responses']; + $all = $this->registry->getAll(); - self::assertArrayHasKey('404', $responses); - self::assertSame('#/components/responses/NotFound', $responses['404']['$ref']); + self::assertCount(1, $all); + self::assertSame('/a', $all[0]->path); } - public function testOperationWithRequestBody(): void + public function testMultipleOperations(): void { - $requestComponent = new Component(); - $requestComponent->id = 'UserForm'; + $get = new Operation(); + $get->id = 'getUser'; + $get->path = '/users/{id}'; + $get->method = 'GET'; - $operation = new Operation(); - $operation->id = 'createUser'; - $operation->path = '/users'; - $operation->method = 'POST'; - $operation->request = $requestComponent; + $post = new Operation(); + $post->id = 'createUser'; + $post->path = '/users'; + $post->method = 'POST'; - $this->registry->register($operation); - - $result = $this->registry->getAll(); - $op = $result['/users']['post']; + $this->registry->register($get); + $this->registry->register($post); - self::assertArrayHasKey('requestBody', $op); - self::assertSame( - '#/components/schemas/UserForm', - $op['requestBody']['content']['application/json']['schema']['$ref'] - ); + self::assertCount(2, $this->registry->getAll()); } - public function testOperationWithSecurity(): void + public function testRegisterReturnsSameModelOnDuplicate(): void { - $operation = new Operation(); - $operation->id = 'securedEndpoint'; - $operation->path = '/secured'; + $operation = new Operation(); + $operation->id = 'op'; + $operation->path = '/x'; $operation->method = 'GET'; - $operation->security = ['default' => []]; - - $this->registry->register($operation); - - $result = $this->registry->getAll(); - $op = $result['/secured']['get']; - self::assertSame([['default' => []]], $op['security']); - } - - public function testMethodIsLowercased(): void - { - $operation = new Operation(); - $operation->id = 'myEndpoint'; - $operation->path = '/test'; - $operation->method = 'DELETE'; - - $this->registry->register($operation); - - $result = $this->registry->getAll(); - - self::assertArrayHasKey('delete', $result['/test']); - self::assertArrayNotHasKey('DELETE', $result['/test']); - } - - public function testMultipleOperationsSamePath(): void - { - $get = new Operation(); - $get->id = 'getUser'; - $get->path = '/users/{id}'; - $get->method = 'GET'; - - $put = new Operation(); - $put->id = 'updateUser'; - $put->path = '/users/{id}'; - $put->method = 'PUT'; - - $this->registry->register($get); - $this->registry->register($put); + $returned = $this->registry->register($operation); + self::assertSame($operation, $returned); - $result = $this->registry->getAll(); + $duplicate = new Operation(); + $duplicate->id = 'op'; + $duplicate->path = '/y'; + $duplicate->method = 'POST'; - self::assertArrayHasKey('get', $result['/users/{id}']); - self::assertArrayHasKey('put', $result['/users/{id}']); + $returned2 = $this->registry->register($duplicate); + self::assertSame($operation, $returned2); } } diff --git a/tests/Unit/Serializer/OpenApiSerializerTest.php b/tests/Unit/Serializer/OpenApiSerializerTest.php new file mode 100644 index 0000000..81943bb --- /dev/null +++ b/tests/Unit/Serializer/OpenApiSerializerTest.php @@ -0,0 +1,318 @@ +serializer = new OpenApiSerializer(); + } + + // ------------------------------------------------------------------------- + // serializeComponents + // ------------------------------------------------------------------------- + + public function testEmptyComponents(): void + { + self::assertSame(['schemas' => []], $this->serializer->serializeComponents([])); + } + + public function testSimpleComponent(): void + { + $component = new Component(); + $component->id = 'UserModel'; + $component->properties = [Property::factory('name', 'string')]; + + [$result] = [$this->serializer->serializeComponents([$component])]; + + self::assertArrayHasKey('UserModel', $result['schemas']); + self::assertSame('string', $result['schemas']['UserModel']['properties']['name']['type']); + } + + public function testComponentWithRequired(): void + { + $component = new Component(); + $component->id = 'RequiredModel'; + $component->properties = [Property::factory('name', 'string')]; + $component->required = ['name']; + + $result = $this->serializer->serializeComponents([$component]); + + self::assertSame(['name'], $result['schemas']['RequiredModel']['required']); + } + + public function testComponentWithoutRequiredOmitsField(): void + { + $component = new Component(); + $component->id = 'OptModel'; + $component->properties = [Property::factory('x', 'string')]; + + $result = $this->serializer->serializeComponents([$component]); + + self::assertArrayNotHasKey('required', $result['schemas']['OptModel']); + } + + public function testPropertyWithRef(): void + { + $ref = new Component(); + $ref->id = 'RefModel'; + + $prop = Property::factory('child', 'object'); + $prop->ref = $ref; + + $component = new Component(); + $component->id = 'Parent'; + $component->properties = [$prop]; + + $result = $this->serializer->serializeComponents([$component]); + $child = $result['schemas']['Parent']['properties']['child']; + + self::assertSame('#/components/schemas/RefModel', $child['$ref']); + self::assertArrayNotHasKey('type', $child); + } + + public function testArrayTypeComponent(): void + { + $ref = new Component(); + $ref->id = 'ItemModel'; + + $itemsProp = Property::factory('items'); + $itemsProp->ref = $ref; + + $component = new Component(); + $component->id = 'MyList'; + $component->type = 'array'; + $component->items = $itemsProp; + + $result = $this->serializer->serializeComponents([$component]); + $schema = $result['schemas']['MyList']; + + self::assertSame('array', $schema['type']); + self::assertSame('#/components/schemas/ItemModel', $schema['items']['$ref']); + } + + public function testPropertyWithFormat(): void + { + $prop = new Component(); + $prop->id = 'M'; + $prop->properties = [Property::factory('email', 'string', 'email')]; + + $result = $this->serializer->serializeComponents([$prop]); + + self::assertSame('email', $result['schemas']['M']['properties']['email']['format']); + } + + public function testPropertyWithEnum(): void + { + $p = Property::factory('status', 'string'); + $p->enum = ['active', 'inactive']; + + $component = new Component(); + $component->id = 'StatusModel'; + $component->properties = [$p]; + + $result = $this->serializer->serializeComponents([$component]); + + self::assertSame(['active', 'inactive'], $result['schemas']['StatusModel']['properties']['status']['enum']); + } + + public function testPropertyWithExtraAttributes(): void + { + $p = Property::factory('username', 'string'); + $p->attributes = ['minLength' => 3, 'maxLength' => 50, 'pattern' => '^[a-z]+$']; + + $component = new Component(); + $component->id = 'UsernameModel'; + $component->properties = [$p]; + + $result = $this->serializer->serializeComponents([$component]); + $schema = $result['schemas']['UsernameModel']['properties']['username']; + + self::assertSame(3, $schema['minLength']); + self::assertSame(50, $schema['maxLength']); + self::assertSame('^[a-z]+$', $schema['pattern']); + } + + public function testExcludedComponentIsOmitted(): void + { + $a = new Component(); + $a->id = 'IncludedModel'; + + $b = new Component(); + $b->id = 'ExcludedForm'; + + $result = $this->serializer->serializeComponents([$a, $b], ['ExcludedForm']); + + self::assertArrayHasKey('IncludedModel', $result['schemas']); + self::assertArrayNotHasKey('ExcludedForm', $result['schemas']); + } + + // ------------------------------------------------------------------------- + // serializePaths + // ------------------------------------------------------------------------- + + public function testSimpleOperation(): void + { + $op = new Operation(); + $op->id = 'getUser'; + $op->path = '/users/{id}'; + $op->method = 'GET'; + $op->description = 'Get a user'; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertArrayHasKey('/users/{id}', $paths); + self::assertArrayHasKey('get', $paths['/users/{id}']); + self::assertSame('getUser', $paths['/users/{id}']['get']['operationId']); + self::assertSame('Get a user', $paths['/users/{id}']['get']['description']); + } + + public function testOperationWithoutDescriptionOmitsField(): void + { + $op = new Operation(); + $op->id = 'list'; + $op->path = '/items'; + $op->method = 'GET'; + $op->description = null; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertArrayNotHasKey('description', $paths['/items']['get']); + } + + public function testOperationWithComponentResponse(): void + { + $response = new Component(); + $response->id = 'UserView'; + $response->status = 200; + $response->headers = ['Content-Type' => 'application/json']; + + $op = new Operation(); + $op->id = 'getUser'; + $op->path = '/users'; + $op->method = 'GET'; + $op->responses = [200 => $response]; + + [$paths] = $this->serializer->serializePaths([$op], null); + + $responses = $paths['/users']['get']['responses']; + self::assertSame('#/components/schemas/UserView', $responses['200']['content']['application/json']['schema']['$ref']); + } + + public function testOperationWithStringResponse(): void + { + $op = new Operation(); + $op->id = 'createUser'; + $op->path = '/users'; + $op->method = 'POST'; + $op->responses = ['404' => 'NotFound']; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertSame('#/components/responses/NotFound', $paths['/users']['post']['responses']['404']['$ref']); + } + + public function testOperationWithRequestBody(): void + { + $requestComponent = new Component(); + $requestComponent->id = 'UserForm'; + + $op = new Operation(); + $op->id = 'createUser'; + $op->path = '/users'; + $op->method = 'POST'; + $op->request = $requestComponent; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertSame( + '#/components/schemas/UserForm', + $paths['/users']['post']['requestBody']['content']['application/json']['schema']['$ref'], + ); + } + + public function testGetRequestExpandsFormAsQueryParams(): void + { + $form = new Component(); + $form->id = 'SearchForm'; + $form->properties = [Property::factory('query', 'string')]; + $form->required = []; + + $op = new Operation(); + $op->id = 'search'; + $op->path = '/search'; + $op->method = 'GET'; + $op->request = $form; + + [$paths, $excludedIds] = $this->serializer->serializePaths([$op], null); + + self::assertArrayNotHasKey('requestBody', $paths['/search']['get']); + self::assertSame('query', $paths['/search']['get']['parameters'][0]['name']); + self::assertSame('query', $paths['/search']['get']['parameters'][0]['in']); + self::assertContains('SearchForm', $excludedIds); + } + + public function testSecurityPlaceholderResolvedToFirstScheme(): void + { + $op = new Operation(); + $op->id = 'secured'; + $op->path = '/secured'; + $op->method = 'GET'; + $op->security = [SecurityParser::SECURITY_PLACEHOLDER => []]; + + [$paths] = $this->serializer->serializePaths([$op], 'BearerAuth'); + + $security = $paths['/secured']['get']['security']; + self::assertArrayHasKey('BearerAuth', $security[0]); + self::assertArrayNotHasKey(SecurityParser::SECURITY_PLACEHOLDER, $security[0]); + } + + public function testSecurityPlaceholderStrippedWhenNoScheme(): void + { + $op = new Operation(); + $op->id = 'secured'; + $op->path = '/secured'; + $op->method = 'GET'; + $op->security = [SecurityParser::SECURITY_PLACEHOLDER => []]; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertArrayNotHasKey('security', $paths['/secured']['get']); + } + + public function testMethodIsLowercased(): void + { + $op = new Operation(); + $op->id = 'del'; + $op->path = '/items/{id}'; + $op->method = 'DELETE'; + + [$paths] = $this->serializer->serializePaths([$op], null); + + self::assertArrayHasKey('delete', $paths['/items/{id}']); + self::assertArrayNotHasKey('DELETE', $paths['/items/{id}']); + } + + public function testMissingPathThrowsLogicException(): void + { + $op = new Operation(); + $op->id = 'bad'; + $op->path = ''; + $op->method = 'GET'; + + $this->expectException(\LogicException::class); + $this->serializer->serializePaths([$op], null); + } +} From 64e0cc9f880c12a27567311207b88af3b6e758eb Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 5 Mar 2026 18:24:13 +0300 Subject: [PATCH 3/3] fix: support EmailType in TextTypeHandler (email block prefix) EmailType has block_prefix 'email', not 'text', so it was not matched by TextTypeHandler and generated type: null in OpenAPI docs. Co-Authored-By: Claude Sonnet 4.6 --- src/Parser/FormTypeHandler/TextTypeHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Parser/FormTypeHandler/TextTypeHandler.php b/src/Parser/FormTypeHandler/TextTypeHandler.php index 9117a01..1af762c 100644 --- a/src/Parser/FormTypeHandler/TextTypeHandler.php +++ b/src/Parser/FormTypeHandler/TextTypeHandler.php @@ -15,7 +15,7 @@ class TextTypeHandler extends AbstractFormTypeHandler { public function supports(string $blockPrefix): bool { - return 'text' === $blockPrefix; + return 'text' === $blockPrefix || 'email' === $blockPrefix; } public function handle(Property $property, FormConfigInterface $config): void