Skip to content

OpenAPI traversal skips callable/actionable procedures #1545

@rwyde

Description

@rwyde

Environment

  • @orpc/server: 1.14.1
  • @orpc/openapi: 1.14.1
  • @orpc/zod: 1.14.1
  • TypeScript: 5.x
  • Node.js: 24.x
  • OS: Linux

Reproduction

import { OpenAPIGenerator, OpenAPIHandler } from '@orpc/openapi'
import { os } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { z } from 'zod'

const plainProcedure = os
  .route({ method: 'GET', path: '/plain' })
  .output(z.object({ ok: z.boolean() }))
  .handler(async () => ({ ok: true }))

const callableProcedure = os
  .route({ method: 'GET', path: '/callable' })
  .output(z.object({ ok: z.boolean() }))
  .handler(async () => ({ ok: true }))
  .callable()

const actionableProcedure = os
  .route({ method: 'GET', path: '/actionable' })
  .output(z.object({ ok: z.boolean() }))
  .handler(async () => ({ ok: true }))
  .actionable()

const router = {
  plain: plainProcedure,
  callable: callableProcedure,
  actionable: actionableProcedure,
}

const generator = new OpenAPIGenerator({
  schemaConverters: [new ZodToJsonSchemaConverter()],
})

const spec = await generator.generate(router, {
  info: {
    title: 'Repro',
    version: '1.0.0',
  },
})

console.log(Object.keys(spec.paths))
// Actual on 1.14.1: ["plain"]
// Expected: ["plain", "callable", "actionable"]

const handler = new OpenAPIHandler(router)

const { matched } = await handler.handle(
  new Request('http://localhost/callable'),
)

console.log(matched)
// Actual on 1.14.1: false
// Expected: true

Describe the bug

Procedures transformed with .callable() or .actionable() are skipped by OpenAPI router traversal in @orpc/server / @orpc/openapi 1.14.1.

The procedures still expose ~orpc and are recognized as procedure-like values, but their runtime type is function because .callable() and .actionable() return function proxies. After PR #1522, traverseContractProcedures returns early for non-object router values:

if (typeof options.router !== 'object' || options.router === null) {
  return lazyOptions
}

Because this guard runs before isContractProcedure(...), function-valued procedures are treated like primitive exports and skipped. This causes:

  • OpenAPIGenerator to omit callable/actionable procedures from generated specs.
  • OpenAPIHandler to fail matching callable/actionable procedures and return matched: false.

This appears to be a regression from 1.13.x. In 1.13.5, traverseContractProcedures checked isContractProcedure(currentRouter) without first excluding function values, so callable/actionable procedure proxies were discovered correctly.

Additional context

PR #1522 fixed an issue where router modules exporting primitives could cause traversal problems or stack overflows. However, ORPC procedures can validly be function-valued after .callable() or .actionable().

A minimal fix may be to recognize procedure-like values before applying the primitive guard, for example by checking isContractProcedure(...) / isProcedure(...) before returning early for non-objects, or by allowing function values that expose a valid ~orpc procedure definition.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions