Skip to content

feat(petstore-hono + openapi-server): battle-tested e2e contract suite + 14 generator fixes#307

Merged
benjamineckstein merged 18 commits into
mainfrom
feature/petstore-e2e-contract-suite
Jun 15, 2026
Merged

feat(petstore-hono + openapi-server): battle-tested e2e contract suite + 14 generator fixes#307
benjamineckstein merged 18 commits into
mainfrom
feature/petstore-e2e-contract-suite

Conversation

@benjamineckstein

Copy link
Copy Markdown
Contributor

What

Ported the proven openapi-laravel /lab/* contract + bug-hunt e2e suite into packages/petstore-hono. The shared spec (spec/api.json) grew from 89 to ~1,100 lines of serialization-seam coverage. The suite is 53 passing tests (6 UI + 47 raw-HTTP contract/bug-hunt) plus 1 documented test.fixme. Tests assert wire-format behavior via Playwright's request fixture against the live generated hono server.

Generator Fixes

The e2e suite surfaced 15 real openapi-server bugs; 14 fixed, 1 documented:

  • Negative-path correctness: malformed/empty body→400, wrong Content-Type→415, error→404 via new generated HttpError
  • Param validation enforcement: query enum/min/max/pattern, header pattern, path numeric range
  • Query styles: delimited arrays, deepObject
  • Body encodings: inline-body safeParse, form-urlencoded, multipart
  • Responses: declared 202, dual-status {status,body} envelope, text/plain + octet-stream
  • Known limitation: int64-beyond-2^53 is a documented JS/JSON limitation (WON'T FIX)

Hardening

  • openapi-server unit tests: 661 → 738
  • Full pnpm -r run test green across all consumers (express/fastify/integration)
  • Regenerate-on-demand means CI always tests current generator output

Deferred Follow-up

Real express/fastify multipart runtime support. Generated stubs currently assume multer / @fastify/multipart middleware.

Full Bug Ledger

The complete bug tracking lives in the planning document (not committed).

Adds packages/petstore-hono/e2e/lab.spec.ts with 29 active + 3 fixme
tests covering constraint round-trips, maps, scalar/discriminated unions,
allOf, tuple, read/write split, loose-union, query/header presence legs,
formats, and the K3 body-shape guard.

- spec/api.json: adds /lab/* paths and component schemas for all Phase 1
  endpoints (LabNumeric, LabString, LabArray, LabFormats, LabEnumConst,
  LabClosed, LabPresence, LabMap, LabUnion, LabAnyOf, LabShape, LabCircle,
  LabSquare, LabInlineShape, LabVehicleBase, LabCar, LabBoat, LabBackedEnum,
  LabPriority, LabBase, LabAllOf, LabVariantItem, LabNestedVariant, LabTuple,
  LabUnionSelector, LabLooseUnion, LabQueryEcho, LabHeaderEcho)
- src/schemas.ts: hand-written Zod schemas for all lab components; custom
  regex for time/duration/hostname formats not natively validated
- src/server/petService.ts: echo handlers for all 24 new service methods;
  nested-variant handler enforces read/write split (strips writeOnly secret,
  assigns server-side serverId)
- e2e/lab.spec.ts: 29 active tests, 3 test.fixme (query constraints,
  header pattern, inline body — documented generator gaps)
Adds 10 new spec paths + components, 1 new Zod schema (LabInt64), and
10 new echo service handlers. Extends lab.spec.ts with 20 Phase 2 tests:
3 active (delimited-query required params, path in-range, int64 small/min),
17 fixme with precise promised/actual/root-cause comments.

Confirmed bugs documented (seams 2-15):
- style/explode not deserialized for array query params
- deepObject not assembled (c.req.query reads wrong key)
- integer path param range not validated
- form-urlencoded body: c.req.json() throws SyntaxError → 500
- multipart body: same root cause → 500
- 202-only response: getResponseStatus() falls through to 200
- dual-status: service has no mechanism to select response status
- text/plain + octet-stream responses: router always emits c.json()
- int64 > MAX_SAFE: Zod v4 implicit int range rejects + JS precision loss
- DELETE nonexistent: thrown Error → Hono 500 not 404
- malformed/empty JSON body: c.req.json() unguarded → 500 not 400
- wrong Content-Type with valid JSON: c.req.json() ignores CT → 200

Total: 37 active pass, 17 fixme (skipped), 0 failed.
Generated Hono handlers now use JSON.parse(await c.req.text()) wrapped in
try/catch instead of c.req.json(). Hono's c.req.json() silently returns null
for an empty body rather than throwing, causing Zod to fire 422. Using
JSON.parse directly ensures SyntaxError propagates to the catch and returns
a clean 400 Bad Request for both malformed and empty JSON bodies.

Fixes K1 (malformed JSON) in the petstore-hono lab suite (K2 empty-body leg
verified correct via native fetch; Playwright Buffer.alloc(0) path now active).
Adds 14 new unit tests in router.test.ts covering all three bug fixes.
…instead of 500

Generated routers for all three targets (Hono, Express, Fastify) now:
  - Export an HttpError class carrying a status code and message
  - Wrap every service call in try/catch: HttpError instances are returned
    as the declared HTTP status; unknown errors rethrow to the framework

Services throw new HttpError(404, '...') to signal 4xx statuses cleanly.
Updated petstore-hono petService to use HttpError(404) for missing pets.

Fixes P12 (DELETE /pets/{id} nonexistent → 404) in the lab suite.
…ams (#2 #3 #6)

Bug #2 — query param constraints (enum/min/max/pattern):
- Extend QueryParam interface with enum, minimum, maximum, exclusiveMinimum,
  exclusiveMaximum, minLength, maxLength, pattern fields.
- getQueryParams() captures constraints from the resolved schema.
- Replace paramZodExpr() with queryParamZodExpr() that chains .enum([]),
  .min()/.max()/.gt()/.lt(), .regex() modifiers.
- queryParamsNeedValidation() now also triggers on constraint-bearing params.

Bug #3 — header param pattern:
- Extend HeaderParam interface with enum, minLength, maxLength, pattern.
- getHeaderParams() captures schema constraints.
- Replace bare z.string() in emitHeaderValidation() with headerParamZodExpr()
  that emits .regex(), .min(), .max() as needed.

Bug #6 — integer path param range:
- pathParamZodExpr() now handles integer/number types: emits
  z.coerce.number().min(x).max(y) (z.coerce handles URL string to number).
- Existing string format validation (uuid, email, url, datetime) is unchanged.

Adds 20 new unit tests in router-param-validation.test.ts (681 total green).
Flips lab.spec.ts fixmes: query enum/min/max/pattern, header pattern, path range.
E2E: 44 passed, 10 fixme, 0 failed.
…rlencoded (#1 #7)

Bug #1: getBodyInfo() now synthesizes a stable schema name from the operationId
for inline (non-$ref) JSON request bodies. The router wires XxxSchema.safeParse
against the synthesized name, returning 422 on violation. A new isSynthesized flag
prevents the synthesized name from leaking into model type imports (no models.ts entry).

Bug #7: getBodyInfo() now recognises application/x-www-form-urlencoded content.
For form bodies the Hono router checks the correct Content-Type and decodes via
c.req.parseBody() instead of JSON.parse. Express/Fastify use req.body as before
(pre-parsed by their respective middleware). Zod coercion handles string-to-number.

12 new unit tests added (693 total, up from 681).
#1 #7)

Bug #1: Add LabInlineBodySchema (title minLength 2, rank int 1-5). The generator
synthesizes LabInlineBody from operationId and wires LabInlineBodySchema.safeParse.
Flip the inline-body constraint-violation fixme to active (3 violation legs).

Bug #7: Add LabFormBodySchema (label string, quantity z.coerce.number().int()).
z.coerce.number() converts string form values to integers after parseBody() decode.
Flip the form-body fixme to active (echo leg: 200 with { label, quantity: 5 }).

E2E: 46 pass / 8 fixme / 0 fail (was 44 pass / 10 fixme).
…ry params (#4 #5)

Bug #4: For query params with style:form/spaceDelimited/pipeDelimited and explode:false,
capture delimiterStyle in shared.ts QueryParam and emit .split(','), .split(' '), or
.split('|') during extraction so params.csv etc. arrive as string[] before Zod.
Emit z.array(z.string()) in the Zod schema for these params.

Bug #5: For query params with style:deepObject (object schema with properties), capture
isDeepObject + deepObjectProperties in shared.ts. In the Hono handler emit c.req.queries()
+ bracket-key filtering to assemble {gte:'10',lte:'100',...}. Express relies on qs
bracket-notation parsing. Fastify emits _dq cast + bracket-key filtering via fast-querystring
raw keys. Zod validates with z.coerce.number() per numeric property.
…ve (#4 #5)

Bug #4: csv/ssv/psv params now arrive as split string[] — labDelimitedQuery handler
echoes params as-is (already correct). Update comment to reflect fixed behavior.

Bug #5: labDeepFilter handler now extracts params.filter (assembled object), coerces
gte/lte to Number(), and returns the flat LabDeepFilterEcho shape. Service comment
updated to describe generator-assembled bracket-notation flow.
getResponseStatus() now scans for a lone 2xx response that is not 200,
201, or 204, and emits that code (e.g. 202) in the router handler.
getReturnInfo() in service.ts receives the same treatment so the
service interface infers the correct return type from 202 content.
All three frameworks (Hono, Express, Fastify) updated.
Adds 4 unit tests (3 router + 1 service) covering the 202 case.
#9)

Service now returns the echoed LabAllOf body so the generated router
can emit c.json(result, 202). fixme on the accepted test upgraded to
an active assertion (status 202 + body echo).
Detect non-JSON success response content types in the generator:
- getReturnInfo() in service.ts now returns primitiveType 'string' for
  text/plain and 'Uint8Array' for application/octet-stream responses
  instead of falling through to Promise<void>.
- getResponseStatus() in router.ts gains responseContentType field
  (driven by detectResponseContentType() helper).
- Hono handler emits c.text() for text/plain, new Response(_result, ...)
  with content-type: application/octet-stream for binary.
- Express handler emits res.type('text/plain').send() and
  res.setHeader('Content-Type', 'application/octet-stream').send(Buffer.from())
  for the respective content types.
- Fastify handler emits reply.type(...).send() for both non-JSON types.
- 12 new unit tests in router.test.ts (8) and service.test.ts (4).
- openapi-server: 723 tests green (was 711).
#12 (#11 #12)

- labPlainText() returns 'lab plain text body' (Promise<string>).
- labDownload() returns new TextEncoder().encode('binary-content') (Promise<Uint8Array>).
- /lab/plain-text and /lab/download test.fixme blocks flipped to active
  tests; both pass: 200 with correct Content-Type header.
- /lab/int64 large-value fixme comment updated to clearly state this is a
  KNOWN, INTENTIONAL LIMITATION (JS/JSON cannot represent > 2^53; Zod v4
  z.number().int() rejects beyond MAX_SAFE_INTEGER). File as open issue
  when branch is pushed, do not attempt a code fix.
- petstore e2e: 51 active / 3 fixme / 0 failed.
When an operation declares more than one 2xx success response (e.g. 200
and 202), the service method now returns { status: number; body: T }
instead of a plain value. The router emits:

  Hono:    c.json(_envelope.body, _envelope.status as any)
  Express: res.status(_envelope.status).json(_envelope.body)
  Fastify: reply.status(_envelope.status).send(_envelope.body)

Single-success ops are unchanged; backward-compat preserved.
727 unit tests green (4 new).
Add optional prefer query param to /lab/dual-status spec. Implement
labDualStatus handler to return { status: 202, body: {phase:'running'} }
when prefer=async and { status: 200, body: {phase:'done'} } otherwise.
Flip test.fixme to active with two assertions: one for 202 and one for 200.

petstore e2e: 52 pass / 2 fixme / 0 fail.
… synthesized imports (#8)

- shared.ts: add 'multipart/form-data' to BodyInfo.contentType union; getBodyInfo()
  detects multipart content and sets isSynthesized:true for inline schemas
- router.ts: Hono emits parseBody({ all: true }) for multipart; Express emits
  req.files+req.body merge (assumes multer); Fastify emits req.body (assumes
  @fastify/multipart) — all three compile; no runtime multipart deps pulled in
- service.ts: synthesized body types (isSynthesized:true — inline JSON, form-urlencoded,
  multipart) now use 'unknown' as body param type and are excluded from the
  import { ... } from './models.js' block, fixing the dangling TS2305 errors for
  LabInlineBody, LabFormBody, LabGallery that were previously emitted
- router.test.ts: 11 new tests for multipart detection/emit across all 3 frameworks
- service.test.ts: 4 new tests asserting synthesized bodies use unknown and have no
  dangling model import
…ery fixme (#8)

- labGallery handler: reads parseBody({ all: true }) result, counts File entries
  under the 'photos' key, returns { count }
- Regenerated generated/service.ts: labGallery(body: unknown) (no dangling import)
- Regenerated generated/router.ts: uses parseBody({ all: true }) for /lab/gallery
- /lab/gallery fixme flipped to active; test passes (1 file uploaded = count 1)
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Fallow audit report

Found 33 findings.

Dependencies (2)
Severity Rule Location Description
minor fallow/unused-dev-dependency examples/package.json:15 Package '@tanstack/react-query' is in devDependencies but never imported; imported in other workspaces: packages/integration
minor fallow/unused-dev-dependency examples/package.json:17 Package 'react' is in devDependencies but never imported; imported in other workspaces: packages/integration
Duplication (31)
Severity Rule Location Description
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:155 Code clone group 1 (17 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:171 Code clone group 2 (19 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:243 Code clone group 3 (20 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:267 Code clone group 4 (22 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:267 Code clone group 5 (26 lines, 4 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:925 Code clone group 3 (20 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:948 Code clone group 4 (22 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:948 Code clone group 5 (26 lines, 4 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1231 Code clone group 3 (20 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1255 Code clone group 4 (22 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1255 Code clone group 5 (26 lines, 4 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1526 Code clone group 6 (11 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1577 Code clone group 6 (11 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1723 Code clone group 7 (8 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1733 Code clone group 7 (8 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/router.test.ts:1929 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/service.test.ts:308 Code clone group 5 (26 lines, 4 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/service.test.ts:652 Code clone group 9 (17 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/service.test.ts:688 Code clone group 9 (17 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/\_\_tests\_\_/service.test.ts:781 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:152 Code clone group 10 (12 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:195 Code clone group 10 (12 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:258 Code clone group 11 (9 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:667 Code clone group 13 (22 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:925 Code clone group 17 (18 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:932 Code clone group 15 (11 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:948 Code clone group 18 (50 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:1091 Code clone group 13 (22 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:1115 Code clone group 14 (15 lines, 3 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/router.ts:1172 Code clone group 18 (50 lines, 2 instances)
minor fallow/code-duplication packages/openapi-server/src/plugins/shared.ts:277 Code clone group 11 (9 lines, 2 instances)

Generated by fallow.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fallow audit report

30 inline findings selected for GitHub review.

Comment thread packages/openapi-server/src/__tests__/router.test.ts
Comment thread packages/openapi-server/src/__tests__/router.test.ts
Comment thread packages/openapi-server/src/__tests__/router.test.ts
Comment thread packages/openapi-server/src/__tests__/router.test.ts
Comment thread packages/openapi-server/src/__tests__/router.test.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/shared.ts
…avior change)

Extract four focused helpers (queryParamDelimitedZodBase, queryParamDeepObjectZodBase,
queryParamNumberZodBase, queryParamStringZodBase) from the monolithic queryParamZodExpr,
reducing its cyclomatic complexity from 18 to 5.

Replace the ten-branch OR chain in queryParamHasConstraints with a small array and
Array.some(), dropping cyclomatic complexity from 10 to 2.

No emitted output changes: all 738 openapi-server tests and 53 petstore e2e tests pass
with byte-identical generated router output.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fallow audit report

28 inline findings selected for GitHub review.

Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
Comment thread packages/openapi-server/src/plugins/router.ts
@benjamineckstein benjamineckstein merged commit 405a924 into main Jun 15, 2026
7 checks passed
@benjamineckstein benjamineckstein deleted the feature/petstore-e2e-contract-suite branch June 15, 2026 21:57
@github-actions github-actions Bot mentioned this pull request Jun 15, 2026
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.

1 participant