Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches:
- '*'
pull_request:
branches:
- '*'

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
php: [ 8.4 ]

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: ${{ matrix.php }}
tools: composer:v2
env:
fail-fast: true

- name: Install Composer dependencies
run: composer install --no-progress

- name: Run tests
run: ./vendor/bin/phpunit --configuration ./phpunit.xml
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Environment

PHP and Composer are **not installed on the host**. All commands must run inside a Docker container with PHP 8.2+. The repo contains no Dockerfile or compose file, so use ad-hoc `docker run`. Confirm with the user if they prefer a specific image/tag before committing scripts.

Suggested invocations from the repo root:

- Install deps: `docker run --rm -v "$PWD":/app -w /app composer:latest install`
- Run all tests: `docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/phpunit tests`
- Run a single test class: `docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/phpunit tests/OpenApi/OpenApiServiceTest.php`
- Run a single test method: `docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/phpunit --filter shouldGenerateJsonWithOpenApi tests`

PHPUnit is configured (`phpunit.xml`) with `failOnDeprecation="true"` and `failOnNotice="true"` — any deprecation/notice fails the suite.

PHP requirement: `>=8.2`. Tests in `tests/` are also autoloaded under the `Ouzo\` PSR-4 prefix (see `composer.json` `autoload-dev`), so test classes live alongside source in the same namespace tree.

## Architecture

This library generates OpenAPI 3.0.1 specs from an Ouzo framework application's routing table. The big picture, in execution order:

1. **`OpenApiService::create()`** is the entry point. It is wired via Ouzo's DI (`#[Inject]` constructors) and orchestrates three collaborators: `PathsService`, `ComponentsService`, and `OpenApiCustomizersRepository`.
2. **`PathsService`** pulls `RouteRule[]` from a user-supplied **`RouteRulesProvider`** (the one interface consumers must implement), filters out `#[Hidden]` controllers/methods via `HiddenChecker`, and turns each rule into a `PathItem`/`Operation` keyed by URI + HTTP method.
3. **`OperationService`** uses reflection on the controller class/method to build `parameters`, `requestBody`, and `responses` — delegating to `ParametersService`, `RequestBodyService`, and `ContentService`. Every type encountered that produces a schema (request/response bodies, nested objects) is registered into `SchemasRepository` as a side effect.
4. **`ComponentsService`** then drains `SchemasRepository` into the OpenAPI `components.schemas` block.
5. **`OpenApiCustomizer`** implementations registered in `OpenApiCustomizersRepository` get the final `OpenApi` object to mutate (typically to set `info` and `servers`, which the core pipeline does not populate).

`SchemasRepository` is the central piece for schema generation and worth understanding before changing type/schema code:
- It is a singleton (see `DefaultOpenApiModule`) so all services share one schema registry.
- `add(Type $type)` recurses through class properties, collecting nested schemas. Already-registered short class names are skipped to avoid cycles.
- Polymorphism is driven by Symfony's `#[DiscriminatorMap]`: parent class becomes a discriminator schema; mapping classes become `allOf` children referencing the parent. `getSchemaForDiscriminator` rewrites property schemas into `oneOf` (or array-of-`oneOf`) when a property's type is a known discriminator parent.
- Backed enums are emitted as `EnumSchema` using the enum's backing type.
- The `#[Schema]` property attribute (`src/OpenApi/Attributes/Schema.php`) marks `required`/`nullable`.

Type resolution lives in `src/OpenApi/Util/Type/`:
- `TypeUtils::getForProperty` builds a `Type` from PHP reflection plus PHPDoc (parsed via `phpdocumentor/reflection-docblock`). PHPDoc is the source of truth for `T[]` array element types — see the `arrayInReturnWithoutTypeInPhpDoc` fixture for the no-PHPDoc fallback behavior.
- `CompoundType` / `ScalarType` / `DocCommentType` enumerate the type categories the rest of the code switches over.

### Extension points for consumers

- `RouteRulesProvider` (required) — supplies the routes to document.
- `OpenApiCustomizer` (optional, multiple) — populate `info`, `servers`, or post-process the spec.
- `#[Hidden]` on controller class or method — exclude from the generated spec.
- `#[Schema(required:, nullable:)]` on DTO properties.

### Testing approach

`OpenApiServiceTest` is a golden-file test: it wires the full graph manually (no DI container), serializes the resulting `OpenApi` via Symfony Serializer, and compares against `tests/OpenApi/expected-openapi.json`. When schema/path output legitimately changes, update that JSON file. Fixtures under `tests/Fixtures/` (especially `SampleController` and `SampleClass`) are the canonical examples of every supported PHP type → OpenAPI shape.
16 changes: 8 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
"minimum-stability": "stable",
"license": "MIT",
"require": {
"php": ">=8.2",
"letsdrink/ouzo": "dev-master#5e92d86",
"symfony/serializer": "^5.4.24",
"phpdocumentor/reflection-docblock": "^5.3.0"
"php": ">=8.4",
"letsdrink/ouzo": "dev-master",
"symfony/serializer": "~7.4.8",
"phpdocumentor/reflection-docblock": "~6.0.3"
},
"require-dev": {
"phpunit/phpunit": "^10.2.2",
"symfony/property-access": "^5.4.22",
"symfony/property-info": "^5.4.24",
"symfony/yaml": "^5.4.23"
"phpunit/phpunit": "~13.1.7",
"symfony/property-access": "~7.4.8",
"symfony/property-info": "~7.4.8",
"symfony/yaml": "~7.4.8"
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 6 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
bootstrap="./vendor/autoload.php"
failOnDeprecation="true"
failOnNotice="true"
>
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
2 changes: 1 addition & 1 deletion src/OpenApi/Model/Media/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Ouzo\OpenApi\Model\Media;

use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Attribute\SerializedName;

/**
* @see https://github.com/OAI/OpenAPI-Specification/blob/3.0.1/versions/3.0.1.md#schemaObject
Expand Down
2 changes: 1 addition & 1 deletion src/OpenApi/OpenApiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __construct(

public function create(): OpenApi
{
$openApi = (new OpenApi())
$openApi = new OpenApi()
->setOpenapi(OpenApiVersion::V_3_0_1);

$paths = $this->pathsService->create();
Expand Down
2 changes: 1 addition & 1 deletion src/OpenApi/Service/ComponentsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public function create(): ?Components
}

$all = $this->schemasRepository->all();
return (new Components())
return new Components()
->setSchemas($all);
}
}
8 changes: 4 additions & 4 deletions src/OpenApi/Service/ContentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ public function create(ReflectionMethod $reflectionMethod): ?Content
$this->schemasRepository->add($type);

if (!is_null($schema)) {
$content = (new Content())
->addMediaType(\Ouzo\Http\MediaType::APPLICATION_JSON, (new MediaType())
$content = new Content()
->addMediaType(\Ouzo\Http\MediaType::APPLICATION_JSON, new MediaType()
->setSchema($schema)
);
}
Expand All @@ -43,8 +43,8 @@ public function extracted(ReflectionParameter $reflectionParameter): Content

$this->schemasRepository->add($type);

return (new Content())
->addMediaType(\Ouzo\Http\MediaType::APPLICATION_JSON, (new MediaType())
return new Content()
->addMediaType(\Ouzo\Http\MediaType::APPLICATION_JSON, new MediaType()
->setSchema($schema)
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/OpenApi/Service/OperationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ public function create(RouteRule $routeRule): Operation

$content = $this->contentService->create($reflectionMethod);

return (new Operation())
return new Operation()
->setTags([$tag])
->setSummary($summary)
->setOperationId($operationId)
->setParameters($parameters)
->setRequestBody($requestBody)
->setResponses((new ApiResponses())
->addApiResponse($responseCode, (new ApiResponse())
->setResponses(new ApiResponses()
->addApiResponse($responseCode, new ApiResponse()
->setDescription('success')
->setContent($content)
)
Expand Down
4 changes: 2 additions & 2 deletions src/OpenApi/Service/ParametersService.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ private function getParameterForPath(ReflectionParameter $reflectionParameter, s
$type = TypeUtils::getForParameter($reflectionParameter);
$schema = SchemaUtils::create($type);

return (new Parameter())
return new Parameter()
->setName($parameterPathName)
->setIn(ParameterIn::PATH)
->setRequired(true)
Expand All @@ -52,7 +52,7 @@ private function getParametersForQueryFromObject(ReflectionParameter $reflection
foreach ($reflectionProperties as $reflectionProperty) {
$type = TypeUtils::getForProperty($reflectionProperty);
$schema = SchemaUtils::create($type);
$parameters[] = (new Parameter())
$parameters[] = new Parameter()
->setName($reflectionProperty->getName())
->setIn(ParameterIn::QUERY)
->setSchema($schema);
Expand Down
2 changes: 1 addition & 1 deletion src/OpenApi/Service/RequestBodyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function create(ReflectionParameter $reflectionParameter, string $httpMet
$isObjectAndNotGetHttpMethod = !$reflectionType->isBuiltin() && $httpMethod !== HttpMethod::GET;
if ($isObjectAndNotGetHttpMethod) {
$content = $this->contentService->extracted($reflectionParameter);
$requestBody = (new RequestBody())
$requestBody = new RequestBody()
->setContent($content);

$type = TypeUtils::getForParameter($reflectionParameter);
Expand Down
20 changes: 10 additions & 10 deletions src/OpenApi/Service/SchemasRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use ReflectionClass;
use ReflectionEnum;
use ReflectionEnumBackedCase;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;

class SchemasRepository
{
Expand All @@ -41,7 +41,7 @@ public function add(Type $type, ?string $pathForReflectionClass = null): void

$includeParentProperties = is_null($pathForReflectionClass);

$schema = (new Schema())
$schema = new Schema()
->setType(CompoundType::OBJECT)
->setDiscriminator($discriminator);

Expand Down Expand Up @@ -78,12 +78,12 @@ public function add(Type $type, ?string $pathForReflectionClass = null): void
}

if (!$includeParentProperties) {
$allOf = [(new Schema())
$allOf = [new Schema()
->setRef($pathForReflectionClass)];
if (!empty($schema->getProperties())) {
$allOf[] = $schema;
}
$schema = (new ComposedSchema())
$schema = new ComposedSchema()
->setAllOf($allOf);
}

Expand All @@ -95,7 +95,7 @@ public function add(Type $type, ?string $pathForReflectionClass = null): void
fn(ReflectionEnumBackedCase $case) => $case->getBackingValue()
);
$schemaType = TypeUtils::convertPhpTypeToOpenApiType($reflectionEnum->getBackingType()->getName());
$schema = (new EnumSchema())->setType($schemaType)->setEnum($values);
$schema = new EnumSchema()->setType($schemaType)->setEnum($values);
}
}

Expand Down Expand Up @@ -123,11 +123,11 @@ private function handleDiscriminatorMap(ReflectionClass $reflectionClass): array
if (!is_null($discriminatorMapAttribute)) {
$pathForReflectionClass = SchemaUtils::getPathForReflectionClass($reflectionClass);

$propertyName = $discriminatorMapAttribute->getTypeProperty();
$propertyName = $discriminatorMapAttribute->typeProperty;
$required[] = $propertyName;
$discriminator = (new Discriminator())
$discriminator = new Discriminator()
->setPropertyName($propertyName);
foreach ($discriminatorMapAttribute->getMapping() as $mappingName => $mappingClass) {
foreach ($discriminatorMapAttribute->mapping as $mappingName => $mappingClass) {
$mappingReflectionClass = new ReflectionClass($mappingClass);
$value = SchemaUtils::getPathForReflectionClass($mappingReflectionClass);
$discriminator->addMapping($mappingName, $value);
Expand All @@ -150,7 +150,7 @@ private function getSchemaForDiscriminator(ReflectionClass $reflectionClass, Typ
return null;
}

$typeSchema = (new ComposedSchema());
$typeSchema = new ComposedSchema();
foreach ($mappingClasses as $mappingClass) {
$schema = SchemaUtils::create($mappingClass);
$typeSchema->addOneOf($schema);
Expand All @@ -160,7 +160,7 @@ private function getSchemaForDiscriminator(ReflectionClass $reflectionClass, Typ
}

if ($type->isArray()) {
return (new ArraySchema())
return new ArraySchema()
->setItems($typeSchema);
}

Expand Down
10 changes: 5 additions & 5 deletions src/OpenApi/Util/SchemaUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static function create(Type $type): ?Schema
if ($type->isArray()) {
if (ScalarType::isScalar($typeName)) {
$schema = self::schemaForPrimitive($typeName, false);
$arraySchema = (new ArraySchema())
$arraySchema = new ArraySchema()
->setItems($schema);

if ($isNullable) {
Expand All @@ -48,7 +48,7 @@ public static function create(Type $type): ?Schema
}

$schema = self::schemaForClass($type);
$arraySchema = (new ArraySchema())
$arraySchema = new ArraySchema()
->setItems($schema);

if ($isNullable) {
Expand All @@ -61,7 +61,7 @@ public static function create(Type $type): ?Schema
$schema = self::schemaForClass($type);

if ($isNullable) {
return (new ComposedSchema())
return new ComposedSchema()
->setAllOf([$schema])
->setNullable(true);
}
Expand All @@ -77,14 +77,14 @@ public static function getPathForReflectionClass(ReflectionClass $class): string
private static function schemaForClass(Type $type): Schema
{
$reflectionClass = $type->getClass();
return (new Schema())
return new Schema()
->setRef(SchemaUtils::getPathForReflectionClass($reflectionClass));
}

private static function schemaForPrimitive(string $type, bool $nullable): Schema
{
$schemaType = TypeUtils::convertPhpTypeToOpenApiType($type);
$schema = (new Schema())
$schema = new Schema()
->setType($schemaType);

if ($nullable) {
Expand Down
2 changes: 1 addition & 1 deletion tests/Fixtures/Polymorphism/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Ouzo\Fixtures\Polymorphism;

use Ouzo\OpenApi\Attributes\Schema;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;

#[DiscriminatorMap(
typeProperty: 'messageType',
Expand Down
Loading