Skip to content

fix: correct OpenAPI generated commands#10

Draft
0xpolarzero wants to merge 1 commit into
typed-client-public-surfacefrom
fix/openapi-generated-command-correctness
Draft

fix: correct OpenAPI generated commands#10
0xpolarzero wants to merge 1 commit into
typed-client-public-surfacefrom
fix/openapi-generated-command-correctness

Conversation

@0xpolarzero
Copy link
Copy Markdown
Owner

@0xpolarzero 0xpolarzero commented May 26, 2026

Overview

Fix correctness bugs in commands generated from OpenAPI specs.

generateCommands() turns OpenAPI operations into incur commands: path parameters become positional args, query parameters and JSON object body properties become options, and run() calls the provided fetch handler. Several edge cases did not preserve that contract.

Issue

Generated commands could differ from the spec in these user-visible ways:

  • path item metadata was treated as fake commands, and path-level parameters were ignored by real operations;
  • OpenAPI 3.2 additionalOperations commands were not generated;
  • path parameter values were interpolated without URL encoding;
  • positional path args followed parameter-array order instead of URL-template order;
  • optional JSON object bodies still required body fields even when the body was omitted;
  • required JSON object bodies sent no body when no body fields were provided;
  • generated boolean parameters used JavaScript truthiness instead of strict boolean parsing.

Tests failing before fix

Path-level parameters

src/Openapi.test.ts

const spec = {
  paths: {
    '/orgs/{orgId}/users': {
      parameters: [{ name: 'orgId', in: 'path', required: true, schema: { type: 'string' } }],
      get: { operationId: 'listOrgUsers' },
    },
  },
}

expect(commands.has('parameters__orgs__orgId__users')).toBe(false)
// Before: true

expect(commands.has('summary__orgs__orgId__users')).toBe(false)
// Before: true for path item metadata like summary

expect(commands.get('listOrgUsers')!.args!.safeParse({ orgId: 'acme' }).success).toBe(true)
// Before: commands.get('listOrgUsers')!.args was undefined

Path item metadata such as parameters and summary should not become commands, and the real get operation should inherit orgId.

OpenAPI 3.2 additional operations

src/Openapi.test.ts

const spec = {
  paths: {
    '/widgets/{id}/actions': {
      parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
      additionalOperations: {
        Search: {
          operationId: 'searchWidgetActions',
          parameters: [{ name: 'cursor', in: 'query', schema: { type: 'string' } }],
        },
      },
    },
  },
}

expect(commands.has('searchWidgetActions')).toBe(true)
// Before: false

await commands.get('searchWidgetActions')!.run({
  args: { id: 'a b' },
  options: { cursor: 'next' },
})
expect(calls[0]).toEqual({
  method: 'Search',
  path: '/widgets/a%20b/actions',
  query: { cursor: 'next' },
})
// Before: no command existed, so this run was impossible.

OpenAPI 3.2 uses additionalOperations for custom operation methods. The generated command should exist, inherit path-level parameters, preserve the custom method key, and build the request URL normally.

Path parameter encoding

src/Openapi.test.ts

await commands.get('getUser')!.run({
  args: { id: 'a/b' },
  options: {},
})

expect(calls).toEqual(['/users/a%2Fb'])
// Before: ['/users/a/b']

Path parameter values are data, not path structure. A slash inside a parameter value must be encoded.

Path arg order

src/Openapi.test.ts

const spec = {
  paths: {
    '/users/{userId}/repos/{repoId}': {
      get: {
        operationId: 'getRepo',
        // Intentionally reversed compared to the URL template.
        parameters: [
          { name: 'repoId', in: 'path', required: true, schema: { type: 'string' } },
          { name: 'userId', in: 'path', required: true, schema: { type: 'string' } },
        ],
      },
    },
  },
}

expect(help).toContain('Usage: test api getRepo <userId> <repoId>')
// Before: 'Usage: test api getRepo <repoId> <userId>'

expect(json(output)).toEqual({ path: '/users/alice/repos/toolkit' })
// Command: test api getRepo alice toolkit --format json
// Before: { path: '/users/toolkit/repos/alice' }

Users type positional path args in URL order. The parameter array order can differ from the path template, so generated args must follow the template.

Optional JSON object bodies

src/Openapi.test.ts

const requestBody = {
  required: false,
  content: {
    'application/json': {
      schema: {
        type: 'object',
        required: ['name'],
        properties: { name: { type: 'string' }, age: { type: 'number' } },
      },
    },
  },
}

expect(options.safeParse({}).success).toBe(true)
// Before: false

expect(options.safeParse({ age: 42 }).success).toBe(false)

If the request body is optional, omitting every body field should be valid. But if any body field is provided, the body schema's required properties still apply.

src/Openapi.test.ts

expect(options.safeParse({}).success).toBe(true)
// Before: false

expect(options.safeParse({ dryRun: false }).success).toBe(false)

A defaulted property should not make an omitted optional body count as provided. Explicitly providing that property should still count as providing the body.

Required JSON object bodies with no provided fields

src/Openapi.test.ts

await commands.get('updateProfile')!.run({ options: {} })
await commands.get('createEmpty')!.run({ options: {} })

expect(bodies).toEqual(['{}', '{}'])
// Before: ['', '']

If the object body itself is required, the generated request still needs to send {} when no body fields are provided.

Strict OpenAPI booleans

src/Openapi.test.ts

const spec = {
  paths: {
    '/users': {
      get: {
        operationId: 'listUsers',
        parameters: [{ name: 'active', in: 'query', schema: { type: 'boolean' } }],
      },
    },
  },
}

expect(options.parse({ active: 'false' }).active).toBe(false)
// Before: true

expect(options.safeParse({ active: 'yes' }).success).toBe(false)
// Before: true

OpenAPI boolean parameters should accept only real booleans and the CLI strings "true" and "false". They should not use JavaScript truthiness.

Boolean enum parameters

src/Openapi.test.ts

const schema = { type: 'boolean', enum: [true, false] }

expect(Parser.parse(['--active'], { options }).options).toEqual({ active: true })
// Before: throws "Missing value for flag: --active"

expect(Parser.parse(['--active=false'], { options }).options).toEqual({ active: false })
// Before: validation failed because "false" was still a string

z.fromJSONSchema() represents boolean enums as a union of boolean literals, not as ZodBoolean. The generator recognizes the common [true, false] enum as a normal strict boolean option.

Regression guards

These tests cover behavior that could regress while fixing the bugs above:

Fix

generateCommands() now:

  • filters path item fields to real OpenAPI operations;
  • supports OpenAPI 3.2 query and additionalOperations;
  • merges path-level and operation-level parameters by in:name;
  • orders path args by path-template order;
  • URL-encodes interpolated path params;
  • tracks which generated options came from JSON object bodies;
  • enforces optional body schemas only after a body field is provided;
  • sends {} for required JSON object bodies when no body field is present;
  • parses generated booleans strictly while preserving CLI flag behavior.

Generated OpenAPI booleans are represented as schemas that accept real booleans plus the CLI strings "true" and "false", then output booleans. Parser/help/completions detect that public input/output shape instead of calling safeParse(true) and safeParse(false), so introspection does not execute user validation code.

Copy link
Copy Markdown
Owner Author

0xpolarzero commented May 26, 2026

@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch 3 times, most recently from bd0622d to 82fdb10 Compare May 26, 2026 16:30
@0xpolarzero 0xpolarzero marked this pull request as ready for review May 26, 2026 16:55
@0xpolarzero 0xpolarzero marked this pull request as draft May 26, 2026 16:58
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 82fdb10 to 566e631 Compare May 26, 2026 17:58
@0xpolarzero 0xpolarzero changed the base branch from main to typed-client-public-surface May 26, 2026 17:58
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from 52cec2a to a0b2d92 Compare May 26, 2026 18:07
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 566e631 to 5ba7b9e Compare May 26, 2026 18:07
@0xpolarzero 0xpolarzero changed the base branch from typed-client-public-surface to graphite-base/10 May 27, 2026 13:03
@0xpolarzero 0xpolarzero changed the base branch from graphite-base/10 to main May 27, 2026 13:14
@0xpolarzero 0xpolarzero changed the base branch from main to graphite-base/10 May 27, 2026 13:19
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 5ba7b9e to 0e7571b Compare May 27, 2026 13:19
@0xpolarzero 0xpolarzero changed the base branch from graphite-base/10 to typed-client-public-surface May 27, 2026 13:19
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch 2 times, most recently from be2d6c8 to fa160fa Compare May 27, 2026 15:11
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch 3 times, most recently from ab3e5d1 to 2518d21 Compare May 27, 2026 16:02
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from fa160fa to 7dafb0a Compare May 27, 2026 16:02
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from 2518d21 to ef926cd Compare May 27, 2026 16:48
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 7dafb0a to b1b7cd8 Compare May 27, 2026 16:48
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from ef926cd to 0dc678e Compare May 27, 2026 17:21
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from b1b7cd8 to 3bc3c58 Compare May 27, 2026 17:21
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 3bc3c58 to 4ea8a93 Compare May 27, 2026 17:23
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch 2 times, most recently from a0219ef to cdcf15a Compare May 27, 2026 17:26
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 4ea8a93 to 35f0956 Compare May 27, 2026 17:26
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from cdcf15a to 376d391 Compare May 27, 2026 18:50
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 35f0956 to a85e2fe Compare May 27, 2026 18:50
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from 376d391 to 56fdb12 Compare May 27, 2026 19:38
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch 2 times, most recently from b845bdb to 97d2f44 Compare May 27, 2026 19:56
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from 56fdb12 to 15a1c55 Compare May 27, 2026 19:56
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 97d2f44 to 7b4938d Compare May 27, 2026 20:03
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from 15a1c55 to 4485b15 Compare May 27, 2026 20:03
@0xpolarzero 0xpolarzero changed the base branch from typed-client-public-surface to graphite-base/10 May 27, 2026 21:05
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 7b4938d to f41724c Compare May 27, 2026 22:47
@0xpolarzero 0xpolarzero changed the base branch from graphite-base/10 to typed-client-public-surface May 27, 2026 22:47
@0xpolarzero 0xpolarzero changed the base branch from typed-client-public-surface to graphite-base/10 May 27, 2026 23:06
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from f41724c to 608fddd Compare May 27, 2026 23:26
@0xpolarzero 0xpolarzero changed the base branch from graphite-base/10 to typed-client-public-surface May 27, 2026 23:26
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch 2 times, most recently from 3426a16 to 7060b71 Compare May 27, 2026 23:38
@0xpolarzero 0xpolarzero force-pushed the typed-client-public-surface branch from ba9090d to b441dc7 Compare May 27, 2026 23:38
@0xpolarzero 0xpolarzero force-pushed the fix/openapi-generated-command-correctness branch from 7060b71 to 06da51e Compare May 28, 2026 13:29
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