Skip to content

feat(support): add ApiError carrier and generated per-operation error-factory classes for spec-declared error responses - #169

Merged
benjamineckstein merged 5 commits into
mainfrom
feat/api-error-factories
Jul 17, 2026
Merged

feat(support): add ApiError carrier and generated per-operation error-factory classes for spec-declared error responses#169
benjamineckstein merged 5 commits into
mainfrom
feat/api-error-factories

Conversation

@benjamineckstein

@benjamineckstein benjamineckstein commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Closes #168.

What & why

From user feedback: a generated abstract controller method's return type is the operation's success DTO by design (error responses are never inspected for typing), so a concrete controller that must answer a spec-declared error status (a 404 ErrorResponse, ...) could not cleanly return an error body, returning a JsonResponse clashes with the success return type ("Expected PatientData, found JsonResponse"), and teams hand-rolled the error DTO the generator already emits from the spec.

The change (two layers; controller return types unchanged, you throw)

1. Support\ApiError , a final, self-rendering throwable inlined into the consumer's own \Support namespace like RespondsWithStatus. Carries a Data body plus an HTTP status and renders through Laravel's render(Request): Response hook, so no bootstrap/app.php wiring is needed. General constructor plus named-status factories as a documented escape hatch.

2. Generated <Operation>Errors factories , one status-keyed static method per spec-declared named-component object error response, flattening the error DTO's constructor into named parameters:

// generated
final class GetPetByIdErrors
{
    public static function notFound(string $message): ApiError
    {
        return new ApiError(new PetNotFoundErrorData(message: $message), 404);
    }
}

// your controller , return type unchanged
public function show(int $petId): PetData
{
    return $this->store->pet($petId)
        ?? throw GetPetByIdErrors::notFound(message: "Pet {$petId} not found.");
}

The status is written once (in the method name), the error DTO is never hand-rolled, and an operation can only throw the errors its spec declares. A shared error schema across statuses (one ErrorResponse at 400/401/403/404) yields one method per status, all forwarding to the same generated DTO.

Scope (v1)

Footprint (additive, minor bump)

  • 34 of 135 corpus specs generate <Operation>Errors classes (32 of the 130 frozen-baseline set). No existing generated file changes , success-path signatures are untouched, so a spec that does not qualify (or --no-controllers) produces byte-identical output.
  • stripe.json produces zero factories (all its errors are declared under default, which v1 omits); the earlier planning estimate overstated the footprint, these are the measured numbers.

Tests / verification

  • Full gate green: composer test 2281 passed, composer stan (PHPStan max) clean, composer lint (Pint) clean, composer deptrac 0 violations, composer test:type 100%.
  • Unit + Feature coverage: factory synthesis (status->method mapping, statusNNN, shared-schema two-methods, partial-qualifying + skip warnings, discriminated-union skip, readOnly/writeOnly READ-variant), a real generate -> HTTP round-trip (404 body plus an unaffected success path), openapi:check drift lockstep for the new files, and the planner --no-controllers veto.
  • Live e2e: the petstore demo swaps PetController::show() to GetPetByIdErrors::notFound(...); a Playwright assertion drives real HTTP , GET /api/v1/pet/<missing> -> 404 {"message": ...}, control -> 200.
  • Corpus rebaseline (ReaderCorpusBaselineTest): the 32 factory-gaining specs are rebaselined under READER_BASELINE_REBASELINED_168, audited per spec; the baseline pipeline now also hashes the new factory-file bucket so the factories gain drift coverage.

Note on bundled commits

This PR also carries 4 pre-existing main fixes (floats / enum / parser / rules). One of them , c670c09 (drop uncompilable \uXXXX patterns) , changed corpus output for aws_iam and sendgrid without a baseline update, so it is rebaselined here under READER_BASELINE_REBASELINED_RULES_UNCOMPILABLE_PATTERN.

Summary by CodeRabbit

  • New Features
    • Generated per-operation error factories for qualifying concrete 4xx/5xx JSON responses.
    • Added ApiError throwable carrier for status-aware JSON error rendering.
  • Bug Fixes
    • Improved numeric handling (scientific notation expansion; rejecting non-finite floats in parsing and constraints).
    • Prevented emitting invalid regex: rules when patterns can’t compile; tightened enum int/string backing inference to avoid non-canonical decimals.
  • Documentation
    • Updated error-handling and stability/versioning guidance; clarified throwing-based controller patterns and updated class/import counts.
  • Tests
    • Expanded unit + end-to-end coverage for error factories, ApiError rendering/round-trips, and generator stability.

…en regex rule

An untrusted-spec `pattern` that is ECMA-valid-but-PCRE-invalid (or
syntactically broken, e.g. `(` or a trailing `\`) was embedded verbatim into a
Laravel `regex:...` rule with no compile probe. Laravel's preg_match then raises
an UNCATCHABLE compile error on every request to that field, a runtime 500/DoS
in the consumer's app that still passes `php -l` and the corpus gate.

regexRule() now probes the delimited pattern with the existing compilesAsPcre()
helper (the same probe closedObjectRule() already runs over patternProperties
patterns) before emitting. A pattern that does not compile is dropped, the field
keeps its other validation rules, and the skip is surfaced as a build warning
via the existing state->warnings channel.

The corpus specs carry only valid PCRE patterns, so output is byte-identical for
all 135 specs (GenerateCorpusTest, PetstoreDriftTest, and the conformance/golden
suites confirm zero drift).

Closes #150
The spec is untrusted input. A non-finite float reaches a numeric keyword from
JSON (`1e400` decodes to INF) or YAML (`.inf`/`.nan`), and `numberValue()`
passed it through unchanged. The emitter then produced a degenerate rule that is
syntactically valid PHP and so cleared every quality gate: `max:NAN` rejects
EVERY value for the field (an availability bug planted purely by spec input),
and `min:INF` / `MultipleOfRule(INF)` are nonsensical.

`numberValue()` now applies a single `is_finite()` guard at the one chokepoint
that feeds all five numeric keywords (minimum, maximum, multipleOf,
exclusiveMinimum, exclusiveMaximum, and transitively the integer-keyword path
via intValue). A non-finite float, native or coerced from an overflowing numeric
string like "1e400", returns null and lands in the existing graceful-ignored
`extra` path, exactly as an absent or non-numeric keyword already does. The
keyword is dropped from the typed schema rather than emitted as a broken rule.

Corpus specs use only finite numbers, so output is byte-identical for all 135
specs (GenerateCorpusTest confirms zero drift).

Closes #151
EnumEmitter inferred an int backing for any unsigned-digit string, then emitted
each case literal as `(int) $value`. A non-canonical decimal string was silently
corrupted: `"01"` became `case Value1 = 1`, so the spec wire value `"01"` no
longer round-tripped (a consumer sending `"01"` never matched the enum), and an
enum carrying both `"01"` and `"1"` collapsed both cases to the SAME `1`,
emitting two `case ... = 1;` lines: a fatal "Duplicate value in enum" PHP error
in the generated app. All of this passed php -l and the corpus gate because the
single-value case still produced syntactically valid PHP.

A new isIntBackable() helper now requires a string to ALSO round-trip through int
unchanged (`(string) (int) $value === $value`) before it counts as int-backable;
the unsigned-digit gate is kept so a signed string like `"-1"` stays
string-backed exactly as before. A non-canonical value (`"01"`, `"040000"`,
`"00"`) falls back to a faithful string backing, preserving the wire value and
keeping sibling cases distinct.

Corpus enum-class output is byte-identical for all 135 specs (verified with a
full before/after diff of every generated enum file): no corpus enum currently
relies on the corrupting path.

Closes #145
PhpLiteral::numberLiteral returned `(string) $value`, which stringifies a small-
or large-magnitude float in scientific notation: `(string) 1e-7` is `"1.0E-7"`,
`(string) 1e20` is `"1.0E+20"`. That form is embedded verbatim into generated
Laravel rule strings (`min:1.0E-7`, `gt:1.0E-7`, `lt:...`, the MultipleOfRule
argument) and into property defaults. In a rule-string parameter the validator
reads the literal text and the `E` is not understood as an exponent, so a
spec-legal tiny `minimum`/`multipleOf` becomes a broken or wrongly-parsed rule;
in a default it is needlessly opaque.

numberLiteral now expands any scientific-notation rendering into plain
fixed-decimal by shifting the decimal point per the exponent, preserving the
exact digits the cast produced (the precision is unchanged, only the format).
A non-scientific value is returned untouched, so every normal-range number is
byte-identical to before and no corpus output drifts.

Closes #148
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds self-rendering ApiError support and generated per-operation error factories, updates generated-output planning and documentation, and adds regression coverage. It also hardens enum backing, numeric literal, regex validation, and finite-number parsing behavior.

Changes

Generated API error handling

Layer / File(s) Summary
ApiError contract and documentation
src/Support/ApiError.php, ROADMAP.md, docs/src/content/docs/guides/*.mdx, composer-require-checker.json
Adds a throwable JSON error carrier with named status factories and documents generated operation factories plus direct ApiError usage.
Error factory generation and output planning
src/Emitter/ErrorFactorySynthesizer.php, src/Emitter/ModelGenerator.php, src/Emitter/Server/OperationCollector.php, src/Emitter/GenerationState.php, src/Console/GenerationPlanner.php
Generates <Operation>Errors classes for qualifying concrete 4xx/5xx named-object responses, flattens DTO constructors, tracks support usage, and gates output on controllers.
Fixtures and validation
tests/Unit/Emitter/*, tests/Feature/Emitter/*, tests/Feature/Support/*, tests/Conformance/*, tests/Corpus/*, tests/Fixtures/*, e2e/*
Covers factory qualification, rendering, planner behavior, drift detection, corpus baselines, and a Petstore 404 response contract.

Enum backing inference

Layer / File(s) Summary
Canonical enum backing
src/Emitter/EnumEmitter.php, tests/Unit/Emitter/EnumBackingTest.php
Requires numeric strings to round-trip canonically through integer conversion before emitting int-backed enums.

Numeric literal rendering

Layer / File(s) Summary
Fixed-decimal numeric literals
src/Emitter/PhpLiteral.php, tests/Unit/Emitter/PhpLiteralTest.php, tests/Unit/Emitter/ValidationConstraintsTest.php
Expands scientific notation into fixed-decimal literals and verifies generated validation constraints preserve those values.

Regex rule validation

Layer / File(s) Summary
PCRE-compatible regex rules
src/Emitter/RulesBuilder.php, tests/Unit/Emitter/ModelGeneratorTest.php
Checks pattern compilability before emitting Laravel regex rules and records warnings for incompatible patterns.

Finite numeric parsing

Layer / File(s) Summary
Finite numeric constraints
src/Parser/OpenApiReader.php, tests/Unit/Security/HostileSpecTest.php
Rejects INF and NAN numeric values while retaining finite numeric constraints.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Controller
  participant OperationErrors
  participant ApiError
  participant Laravel
  Controller->>OperationErrors: throw status-specific factory
  OperationErrors->>ApiError: construct DTO carrier with HTTP status
  ApiError->>Laravel: render JSON response
Loading

Possibly related issues

  • #145 — Adds canonical integer round-trip validation for numeric-string enum values.
  • #148 — Expands scientific-notation floats into fixed-decimal literals.
  • #150 — Prevents emission of PCRE-invalid regex rules.
  • #151 — Ignores non-finite numeric schema constraints.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated enum, numeric literal, parser, and regex-rule fixes plus their tests/docs, which are outside #168's error-handling scope. Split the unrelated fixes into separate PRs or link an issue that explicitly scopes them into this change.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: ApiError plus generated per-operation error factories.
Linked Issues check ✅ Passed The PR implements ApiError, per-operation Errors factories, throwing behavior, controller gating, and the stated warning/skip rules from #168.
Docstring Coverage ✅ Passed Docstring coverage is 84.31% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-error-factories

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Qodana for PHP

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Detected 12 dependencies

Third-party software list

This page lists the third-party software dependencies used in project

Dependency Version Licenses
doctrine/deprecations 1.1.6 MIT
phpdocumentor/reflection-common 2.2.0 MIT
phpdocumentor/reflection-docblock 6.0.3 MIT
phpdocumentor/type-resolver 2.0.0 MIT
phpstan/phpdoc-parser 2.3.3 MIT
spatie/laravel-data 4.23.0 MIT
spatie/laravel-package-tools 1.93.1 MIT
spatie/php-structure-discoverer 2.4.4 MIT
symfony/finder v8.1.1 MIT
symfony/polyfill-ctype v1.37.0 MIT
symfony/yaml v8.1.1 MIT
webmozart/assert 2.4.1 MIT
Contact Qodana team

Contact us at qodana-support@jetbrains.com

Comment thread src/Emitter/ErrorFactorySynthesizer.php Fixed
Comment thread src/Emitter/RulesBuilder.php Fixed
Comment thread src/Support/ApiError.php Fixed
@benjamineckstein
benjamineckstein force-pushed the feat/api-error-factories branch from 4864e58 to 6364ef8 Compare July 16, 2026 19:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/Unit/Security/HostileSpecTest.php (1)

227-260: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover numeric-string overflow and exclusive bounds.

The new "1e400" coercion guard and the exclusiveMinimum/exclusiveMaximum call paths remain untested. Add cases confirming they also emit no non-finite constraints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Unit/Security/HostileSpecTest.php` around lines 227 - 260, Extend the
numeric constraint tests around generateNumberConstraint to cover numeric-string
overflow such as "1e400" and the exclusiveMinimum/exclusiveMaximum paths. Assert
that non-finite coerced values produce no corresponding exclusive minimum or
maximum constraints, including no INF/NAN output, while preserving the existing
finite-bound coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Emitter/Server/OperationCollector.php`:
- Around line 1895-1897: Update the response-slot filtering logic in
OperationCollector to record warnings whenever default, 4XX/5XX wildcard, or
other unsupported error responses are skipped, including their specific skip
reasons. Ensure accumulated $skipped diagnostics are emitted before the early
return when $slots is empty, so inline-schema warnings are not suppressed; add
coverage for operations containing only unsupported error slots.

---

Nitpick comments:
In `@tests/Unit/Security/HostileSpecTest.php`:
- Around line 227-260: Extend the numeric constraint tests around
generateNumberConstraint to cover numeric-string overflow such as "1e400" and
the exclusiveMinimum/exclusiveMaximum paths. Assert that non-finite coerced
values produce no corresponding exclusive minimum or maximum constraints,
including no INF/NAN output, while preserving the existing finite-bound
coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cc0057ec-5732-420d-8325-8490b0f048d0

📥 Commits

Reviewing files that changed from the base of the PR and between 444a419 and 6364ef8.

📒 Files selected for processing (40)
  • ROADMAP.md
  • composer-require-checker.json
  • docs/src/content/docs/guides/runtime-coupling.mdx
  • docs/src/content/docs/guides/server-scaffold.mdx
  • docs/src/content/docs/guides/stability.mdx
  • docs/src/content/docs/guides/validation-errors.mdx
  • docs/src/content/docs/guides/versioning-policy.mdx
  • e2e/backend/.gitignore
  • e2e/backend/app/Http/Controllers/Api/PetController.php
  • e2e/e2e-tests/tests/petstore.spec.ts
  • e2e/spec/petstore.yaml
  • src/Console/GenerationPlanner.php
  • src/Emitter/EnumEmitter.php
  • src/Emitter/ErrorFactorySynthesizer.php
  • src/Emitter/GenerationState.php
  • src/Emitter/ModelGenerator.php
  • src/Emitter/PhpLiteral.php
  • src/Emitter/RulesBuilder.php
  • src/Emitter/Server/OperationCollector.php
  • src/Parser/OpenApiReader.php
  • src/Support/ApiError.php
  • tests/Conformance/ConformanceGoldenTest.php
  • tests/Corpus/GeneratedOutputPhpstanTest.php
  • tests/Corpus/GeneratedOutputPintTest.php
  • tests/Corpus/ReaderCorpusBaselineTest.php
  • tests/Feature/Console/CheckCommandTest.php
  • tests/Feature/Emitter/ApiErrorRoundTripTest.php
  • tests/Feature/Support/ApiErrorRenderTest.php
  • tests/Fixtures/conformance/conformance-3.1.yaml
  • tests/Fixtures/corpus-baseline-v0.11.0.json
  • tests/Fixtures/server/api-error.yaml
  • tests/Unit/Console/GenerationPlannerTest.php
  • tests/Unit/Emitter/EnumBackingTest.php
  • tests/Unit/Emitter/ErrorFactorySynthesizerTest.php
  • tests/Unit/Emitter/ModelGeneratorTest.php
  • tests/Unit/Emitter/PhpLiteralTest.php
  • tests/Unit/Emitter/Server/OperationCollectorTest.php
  • tests/Unit/Emitter/ValidationConstraintsTest.php
  • tests/Unit/Security/HostileSpecTest.php
  • tests/Unit/Support/ApiErrorTest.php

Comment thread src/Emitter/Server/OperationCollector.php
@benjamineckstein
benjamineckstein force-pushed the feat/api-error-factories branch from 6364ef8 to 4dc155b Compare July 16, 2026 20:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/Emitter/Server/OperationCollector.php (1)

1895-1898: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Record wildcard/default skip reasons to ensure they are warned about.

The PR objective states that default, 4XX, and 5XX wildcard responses are "skipped with warnings". However, the continue here bypasses the $skipped array, meaning these non-concrete statuses will never be included in the warnings, even when an operation successfully gets a factory.

🐛 Proposed fix to populate `$skipped`
             // v1: only concrete 4xx/5xx codes get a factory method (400-599).
             if (preg_match('~^[45][0-9][0-9]$~', $status) !== 1) {
+                $skipped[] = ['status' => $status, 'reason' => 'only concrete 4xx/5xx statuses are supported in this version'];
                 continue;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Emitter/Server/OperationCollector.php` around lines 1895 - 1898, Update
the status-filtering branch in OperationCollector’s response collection logic so
non-concrete statuses such as default, 4XX, and 5XX are recorded in the existing
$skipped array before continuing. Preserve the concrete 400–599
factory-generation path and ensure successfully collected operations can later
warn about these skipped statuses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/Emitter/Server/OperationCollector.php`:
- Around line 1895-1898: Update the status-filtering branch in
OperationCollector’s response collection logic so non-concrete statuses such as
default, 4XX, and 5XX are recorded in the existing $skipped array before
continuing. Preserve the concrete 400–599 factory-generation path and ensure
successfully collected operations can later warn about these skipped statuses.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42e31b62-8c03-4984-9f07-bdd7c8e0b2ad

📥 Commits

Reviewing files that changed from the base of the PR and between 6364ef8 and 4dc155b.

📒 Files selected for processing (31)
  • ROADMAP.md
  • composer-require-checker.json
  • docs/src/content/docs/guides/runtime-coupling.mdx
  • docs/src/content/docs/guides/server-scaffold.mdx
  • docs/src/content/docs/guides/stability.mdx
  • docs/src/content/docs/guides/validation-errors.mdx
  • docs/src/content/docs/guides/versioning-policy.mdx
  • e2e/backend/.gitignore
  • e2e/backend/app/Http/Controllers/Api/PetController.php
  • e2e/e2e-tests/tests/petstore.spec.ts
  • e2e/spec/petstore.yaml
  • src/Console/GenerationPlanner.php
  • src/Emitter/ErrorFactorySynthesizer.php
  • src/Emitter/GenerationState.php
  • src/Emitter/ModelGenerator.php
  • src/Emitter/Server/OperationCollector.php
  • src/Support/ApiError.php
  • tests/Conformance/ConformanceGoldenTest.php
  • tests/Corpus/GeneratedOutputPhpstanTest.php
  • tests/Corpus/GeneratedOutputPintTest.php
  • tests/Corpus/ReaderCorpusBaselineTest.php
  • tests/Feature/Console/CheckCommandTest.php
  • tests/Feature/Emitter/ApiErrorRoundTripTest.php
  • tests/Feature/Support/ApiErrorRenderTest.php
  • tests/Fixtures/conformance/conformance-3.1.yaml
  • tests/Fixtures/corpus-baseline-v0.11.0.json
  • tests/Fixtures/server/api-error.yaml
  • tests/Unit/Console/GenerationPlannerTest.php
  • tests/Unit/Emitter/ErrorFactorySynthesizerTest.php
  • tests/Unit/Emitter/Server/OperationCollectorTest.php
  • tests/Unit/Support/ApiErrorTest.php
🚧 Files skipped from review as they are similar to previous changes (24)
  • tests/Corpus/GeneratedOutputPhpstanTest.php
  • src/Emitter/GenerationState.php
  • docs/src/content/docs/guides/stability.mdx
  • e2e/backend/.gitignore
  • tests/Unit/Support/ApiErrorTest.php
  • src/Emitter/ErrorFactorySynthesizer.php
  • docs/src/content/docs/guides/server-scaffold.mdx
  • src/Console/GenerationPlanner.php
  • e2e/backend/app/Http/Controllers/Api/PetController.php
  • tests/Corpus/GeneratedOutputPintTest.php
  • composer-require-checker.json
  • e2e/e2e-tests/tests/petstore.spec.ts
  • docs/src/content/docs/guides/validation-errors.mdx
  • tests/Fixtures/conformance/conformance-3.1.yaml
  • e2e/spec/petstore.yaml
  • docs/src/content/docs/guides/versioning-policy.mdx
  • tests/Conformance/ConformanceGoldenTest.php
  • tests/Unit/Console/GenerationPlannerTest.php
  • ROADMAP.md
  • tests/Unit/Emitter/Server/OperationCollectorTest.php
  • tests/Feature/Support/ApiErrorRenderTest.php
  • src/Emitter/ModelGenerator.php
  • tests/Corpus/ReaderCorpusBaselineTest.php
  • tests/Unit/Emitter/ErrorFactorySynthesizerTest.php

…-factory classes for spec-declared error responses

Generated abstract controller methods are typed to the operation's success DTO
by design, so a concrete controller could not cleanly answer a spec-declared
error status: returning a JsonResponse clashed with the success return type, and
teams hand-rolled the error DTO the generator already emits from the spec.

Add two layers. Controller return types are unchanged: errors are thrown, and a
throw satisfies any return type.

- Support\ApiError: a final, self-rendering throwable inlined into the
  consumer's own \Support namespace like RespondsWithStatus, carrying a Data
  body plus an HTTP status and rendering via Laravel's render(Request): Response
  hook, so no bootstrap/app.php registration is needed. General constructor plus
  named-status factories as a documented escape hatch.

- Generated <Operation>Errors factories: one status-keyed static method per
  spec-declared named-component object error response, flattening the error
  DTO's constructor into named parameters and forwarding into
  new ApiError(new <Schema>Data(...), <status>). Placed in the tag-grouped Data
  namespace; emission gated by --no-controllers.

      throw GetPetByIdErrors::notFound(message: "Pet {$petId} not found.");

The status is written once (in the method name), the error DTO is never
hand-rolled, and an operation can only throw the errors its spec declares. A
shared error schema across statuses yields one method per status, all
forwarding to the same generated DTO.

v1 covers named-component object error schemas; inline-object schemas and the
default/4XX/5XX wildcards are deferred (warn-and-skip), matching the
component-then-inline staging precedent (#110/#76, #116/#129). Extends decision
#11 without a generated renderer: ApiError is a schema-agnostic carrier the
developer fills with an already-generated DTO.

Includes the ReaderCorpusBaselineTest rebaseline: 32 corpus specs gain factory
classes (READER_BASELINE_REBASELINED_168), and aws_iam/sendgrid whose output
also shifted from the bundled uncompilable-pattern fix c670c09
(READER_BASELINE_REBASELINED_RULES_UNCOMPILABLE_PATTERN). Docs, ROADMAP #11
addendum, and a live e2e petstore demo included.

Closes #168
@benjamineckstein
benjamineckstein force-pushed the feat/api-error-factories branch from 4dc155b to dac90d2 Compare July 17, 2026 05:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Support/ApiError.php`:
- Around line 129-132: Update ApiError::render to detect when $this->body
implements Responsable and call its toResponse($request) method directly,
returning that response unchanged. Preserve the existing response()->json flow
for bodies that are not Responsable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 76a2d567-10e5-40cb-9688-48221e732373

📥 Commits

Reviewing files that changed from the base of the PR and between 4dc155b and dac90d2.

📒 Files selected for processing (34)
  • ROADMAP.md
  • composer-require-checker.json
  • docs/src/content/docs/guides/runtime-coupling.mdx
  • docs/src/content/docs/guides/server-scaffold.mdx
  • docs/src/content/docs/guides/stability.mdx
  • docs/src/content/docs/guides/validation-errors.mdx
  • docs/src/content/docs/guides/versioning-policy.mdx
  • e2e/backend/.gitignore
  • e2e/backend/app/Http/Controllers/Api/PetController.php
  • e2e/e2e-tests/tests/petstore.spec.ts
  • e2e/spec/petstore.yaml
  • qodana.yaml
  • src/Console/GenerationPlanner.php
  • src/Emitter/ErrorFactorySynthesizer.php
  • src/Emitter/GenerationState.php
  • src/Emitter/ModelGenerator.php
  • src/Emitter/RulesBuilder.php
  • src/Emitter/Server/OperationCollector.php
  • src/Support/ApiError.php
  • tests/Conformance/ConformanceGoldenTest.php
  • tests/Corpus/GeneratedOutputPhpstanTest.php
  • tests/Corpus/GeneratedOutputPintTest.php
  • tests/Corpus/ReaderCorpusBaselineTest.php
  • tests/Feature/Console/CheckCommandTest.php
  • tests/Feature/Emitter/ApiErrorRoundTripTest.php
  • tests/Feature/Support/ApiErrorRenderTest.php
  • tests/Fixtures/conformance/conformance-3.1.yaml
  • tests/Fixtures/corpus-baseline-v0.11.0.json
  • tests/Fixtures/server/api-error.yaml
  • tests/Unit/Console/GenerationPlannerTest.php
  • tests/Unit/Emitter/ErrorFactorySynthesizerTest.php
  • tests/Unit/Emitter/Server/OperationCollectorTest.php
  • tests/Unit/Security/HostileSpecTest.php
  • tests/Unit/Support/ApiErrorTest.php
🚧 Files skipped from review as they are similar to previous changes (24)
  • tests/Corpus/GeneratedOutputPhpstanTest.php
  • e2e/backend/app/Http/Controllers/Api/PetController.php
  • docs/src/content/docs/guides/versioning-policy.mdx
  • composer-require-checker.json
  • tests/Unit/Console/GenerationPlannerTest.php
  • e2e/backend/.gitignore
  • src/Emitter/RulesBuilder.php
  • docs/src/content/docs/guides/stability.mdx
  • tests/Unit/Support/ApiErrorTest.php
  • ROADMAP.md
  • tests/Unit/Security/HostileSpecTest.php
  • tests/Corpus/GeneratedOutputPintTest.php
  • tests/Feature/Support/ApiErrorRenderTest.php
  • tests/Conformance/ConformanceGoldenTest.php
  • tests/Unit/Emitter/Server/OperationCollectorTest.php
  • src/Console/GenerationPlanner.php
  • src/Emitter/GenerationState.php
  • docs/src/content/docs/guides/validation-errors.mdx
  • docs/src/content/docs/guides/server-scaffold.mdx
  • src/Emitter/ErrorFactorySynthesizer.php
  • src/Emitter/ModelGenerator.php
  • src/Emitter/Server/OperationCollector.php
  • tests/Corpus/ReaderCorpusBaselineTest.php
  • tests/Unit/Emitter/ErrorFactorySynthesizerTest.php

Comment thread src/Support/ApiError.php
@benjamineckstein
benjamineckstein merged commit b95b1ab into main Jul 17, 2026
15 checks passed
@benjamineckstein
benjamineckstein deleted the feat/api-error-factories branch July 17, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generated error throwing: ApiError carrier + per-operation <Operation>Errors factories

2 participants