Skip to content

feat(openapi): add profile-driven OpenAPI 3.1 service and type binding (generate-openapi) - #89

Merged
gsmith85 merged 4 commits into
mainfrom
feat/codegen-openapi
Oct 26, 2026
Merged

gsmith85 merged 4 commits into
mainfrom
feat/codegen-openapi

Conversation

@gsmith85

@gsmith85 gsmith85 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Task 11 (PR 5) of the two-layer ucp-schema code-generation pipeline: Layer 2 profile-driven OpenAPI 3.1.0 service and type binding (ucp-schema generate-openapi / src/openapi/mod.rs), stacked on #88.

Changes

  • Layer 2 Profile-Driven OpenAPI Binding (src/openapi/mod.rs, src/codegen/profile.rs):
    • Implements GenerateOpenApiOptions (profile, service, server_url), rewrite_defs_to_component_schemas, and generate_openapi().
    • Parses and validates the discovery profile via parse_profile_source(), filters "transport": "rest" service bindings by optional --service, and returns CodegenError::NoRestServiceBinding (exit code 2) on MCP/embedded-only profiles or CodegenError::MissingServiceSchema (exit code 2) when "schema" is absent.
    • Resolves and merges all selected REST service OpenAPI 3.1 templates (services/{shopping,lodging,common}/rest.openapi.json), setting "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base" and populating "servers" from --server-url or the profile's deduplicated REST "endpoint" URLs.
    • Compiles active capability and extension types via compile_types() and prunes inactive routes in paths and webhooks (removing "webhooks" when empty).
    • Eliminates all snake_case template wrapper/alias schemas from components.schemas and binds operation requestBody and responses directly to canonical PascalCase #/components/schemas/<Name> definitions (<Cap>{Create,Update,Complete}Request, container operation request/response schemas, Order, UcpBase, and inlined oneOf: [<Primary>, ErrorResponse] on error-wrapped responses).
    • Rewrites #/$defs/<Name> to #/components/schemas/<Name> and prunes components.schemas, components.parameters, and components.headers to the transitive reachability closure of the retained routes and webhooks (with zero unused ambient schemas or parameters and zero "discriminator" keywords).
  • CLI Subcommand & Library Exports (src/lib.rs, src/bin/ucp-schema.rs):
    • Exports pub mod openapi; and pub use openapi::{generate_openapi, rewrite_defs_to_component_schemas, GenerateOpenApiOptions}; in src/lib.rs.
    • Wires ucp-schema generate-openapi (--profile / -p, --service, --server-url, --output / -o, --pretty) in src/bin/ucp-schema.rs.
  • Integration Tests (tests/generate_openapi_test.rs):
    • Covers shopping (checkout + discount + fulfillment, order + webhooks.orderEvent, catalog.search, catalog.lookup), common (location.search, location.lookup, ask), lodging (booking), multi-service merging & --service filtering, --server-url override, error exit codes, and OpenAPI 3.1 structural invariants.

Stacked PR Series Roadmap

  1. PR 1a (feat(codegen): add identifier naming, UCP keyword stripping, and $defs ref rewriting #81): Identifier qualification, UCP keyword stripping, $ref rewriting (src/codegen/normalizer.rs)
  2. PR 1b (feat(codegen): add directional schema slicing and $ref alignment #82): Directional schema slicing & $ref alignment (src/codegen/normalizer.rs)
  3. PR 1c (feat(codegen): add capability reachability, composition, and generate_types() API #83): Capability/extension reachability, composition & generate_types() library API (src/codegen/{mod,reachability,hoist,compose}.rs)
  4. PR 1d (feat(cli): add generate-types subcommand #84): CLI generate-types subcommand (src/bin/ucp-schema.rs, tests/generate_types_test.rs)
  5. PR 2 (feat(codegen): add self-contained .well-known/ucp profile scoping for generate-types #85): Self-contained .well-known/ucp --profile scoping (src/codegen/profile.rs)
  6. PR 3a (feat(codegen): add AST shape normalizers and inline conditional variant hoisting #86): Codegen AST shape normalizers & inline conditional variant hoisting (src/codegen/normalizer.rs, src/codegen/reify.rs)
  7. PR 3b (feat(codegen): lower open string discriminator unions into ordered anyOf with <Union>Base #87): Open string discriminator union lowering (<Union>Base) (src/codegen/reify.rs)
  8. PR 3c (feat(codegen): discover and register decentralized extended subtypes into open unions #88): Cross-file extended subtype discovery & registration (src/codegen/reify.rs)
  9. PR 4a (python-sdk#101): Python SDK direct JSON Schema codegen (generate_models.sh, postprocess_models.py, src/ucp_sdk/models/)
  10. PR 4b (js-sdk#87): JS/TS SDK direct JSON Schema codegen (generate_models.sh, scripts/generate-zod-from-types.mjs, src/spec_generated.ts)
  11. PR 5 (this PR): OpenAPI 3.1 REST service & type binding (ucp-schema generate-openapi, src/openapi/mod.rs)

Test Plan

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all-targets

@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 14edfcd to 0cc5894 Compare October 14, 2026 22:04
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch 2 times, most recently from 0cf816a to 86c1bf1 Compare October 14, 2026 23:23
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 0cc5894 to 4387d0f Compare October 14, 2026 23:23
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 86c1bf1 to e1c906e Compare October 15, 2026 01:18
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch 2 times, most recently from 82faf0a to c540328 Compare October 15, 2026 01:43
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch 2 times, most recently from 28ba505 to ce122a4 Compare October 15, 2026 01:56
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch 2 times, most recently from 0405926 to 8f9d2f3 Compare October 15, 2026 20:22
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch 2 times, most recently from 65b0a52 to fdd65fb Compare October 15, 2026 22:49
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 8f9d2f3 to 6dc0aaf Compare October 15, 2026 22:49
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from fdd65fb to fc967d0 Compare October 16, 2026 02:22
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 6dc0aaf to 3f2732d Compare October 16, 2026 02:22
@vishkaty

Copy link
Copy Markdown
Contributor

Reviewed this as a downstream consumer: our conformance suite drives ucp-schema as a schema oracle, so a new emitter that binds profiles to OpenAPI is something we would want to run. Checked at head 3f2732db, base feat/codegen-subtype-discovery at fc967d09, against the ucp tree at d9c18e0d.

The binding itself does what the body says. On the ucp tree at d9c18e0d a checkout only profile emits 4 of the 11 paths in the shopping template, exactly the /checkout-sessions routes, with 53 component schemas and all 189 $ref values resolving inside #/components with none dangling, and jsonSchemaDialect is set as described. One thing, plus a note on coverage.

1. info.version is dropped, so the output fails OpenAPI 3.1 validation.

load_and_merge_templates seeds a default info at src/openapi/mod.rs:214:

info: json!({ "title": "UCP REST Service", "version": "1.0.0" }),

and then, for the first selected service at src/openapi/mod.rs:235, replaces it wholesale:

if let Some(info) = root_obj.get("info") {
    merged.info = info.clone();
}

Every UCP REST service template has an info with no version, so the default is always discarded and never replaced. On ucp main d9c18e0d:

template info keys
source/services/shopping/rest.openapi.json title, description
source/services/lodging/rest.openapi.json title, description
source/services/common/rest.openapi.json title, description
source/services/shopping/permalink.openapi.json title, description
source/handlers/tokenization/openapi.json title, description, version

The four without it are every REST service template in the tree; the one with it is the tokenization handler, which is not a service binding. So this fires for any REST profile rather than on an edge case. Observed, with a minimal shopping profile pointing at the local tree:

$ ucp-schema generate-openapi --profile shopping.json --output out.json
$ echo $?
0
$ python -c "import json;print(sorted(json.load(open('out.json'))['info']))"
['description', 'title']
$ openapi-spec-validator out.json          # openapi-spec-validator 0.9.0
out.json: Validation Error: 'version' is a required property
$ echo $?
1

(the validator also dumps the info schema and instance after that line; trimmed here)

The tool exits 0 with empty stderr. info.version is REQUIRED by the Info Object in OpenAPI 3.1.0 section 4.8.2 (https://spec.openapis.org/oas/v3.1.0#info-object), so a conformance validator rejects the document. The same run against a lodging profile gives the identical result. Making the merge additive rather than replacing, so a template without version keeps the seeded default, fixes it in the one place; whether the right value is 1.0.0 or the profile ucp.version is a call for this repo.

Why the tests do not catch it: tests/generate_openapi_test.rs never asserts anything about info, and it checks jsonSchemaDialect without validating the document against the OAS 3.1 schema, which is the check that would catch a missing required field. Adding that assertion to one existing case would cover the whole class rather than this field alone.

Nor has any job run them. ci.yml triggers on pull_request to main only, so a PR against a feature branch gets no fmt, clippy, MSRV or test run, and all five tests in tests/generate_openapi_test.rs return early without the ../ucp/source sibling checkout, the same pattern I noted on #85. Both are repo level matters rather than anything for this PR; I mention them only because together they mean the info behaviour above has never been exercised by a job.

Happy to send the info merge as a small PR against this branch if that is useful.

@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 5b4a2da to 40c88a0 Compare October 21, 2026 01:17
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 700efcc to 273ae29 Compare October 21, 2026 01:17
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 40c88a0 to 4c8b88e Compare October 21, 2026 01:42
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch 2 times, most recently from f631dab to 652d786 Compare October 21, 2026 02:42
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch 2 times, most recently from 850b9fd to 9557e30 Compare October 21, 2026 03:01
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 652d786 to 706729c Compare October 21, 2026 03:01
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 9557e30 to 33a8af6 Compare October 21, 2026 03:46
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 706729c to 81166bd Compare October 21, 2026 03:48
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 33a8af6 to 0572660 Compare October 21, 2026 04:09
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 81166bd to ecb38b9 Compare October 21, 2026 04:09
@gsmith85
gsmith85 requested a review from jingyli October 21, 2026 18:20
@gsmith85
gsmith85 force-pushed the feat/codegen-subtype-discovery branch from 0572660 to 7805e50 Compare October 23, 2026 05:00

@jingyli jingyli 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.

Thanks @gsmith85! Would be great if we can rebase & resolve merge conflicts so I can take a final pass on the PR's latest state.

Comment thread src/openapi/mod.rs Outdated
if ep.is_empty() || !seen.insert(ep.to_string()) {
continue;
}
endpoints.push(json!({ "url": ep }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Given this is a vector, it will collected all the selected service's endpoint into one array. So if a developer chooses to support multiple services in their profile, we would end up with multiple server URLs but never specify which sub-path is relevant for which server (this ends up making an assumption that all paths are applicable for all server endpoints) - this would become a big problem for any REST transport bindings.

It might be better to return a map of server endpoint to applicable paths instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch! Addressed in 011f26c:

  1. Each selected REST service template is now bound and pruned independently before merging (so inactive services do not leak endpoints/metadata, and colliding active routes across distinct services return CodegenError::ConflictingServicePath).
  2. When retained routes span distinct service endpoints, root servers is omitted and each retained Path Item Object receives its own OAS 3.1 servers array (paths[path].servers); when all retained routes share an identical server list (single service, --service filter, --server-url override, or shared gateway URL), root servers is emitted cleanly with no per-path repetition.

@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from ecb38b9 to 3563d15 Compare October 26, 2026 15:26
Base automatically changed from feat/codegen-subtype-discovery to main October 26, 2026 15:33
…ervers

- Bind and prune each selected REST service template independently before merging so inactive services do not leak endpoints, info, or parameters and cross-service path collisions fail with ConflictingServicePath (exit code 2)
- Emit OAS 3.1 Path Item Object.servers (omitting root servers) when retained routes span distinct service endpoints while keeping uniform endpoints at root servers
- Guard against whitespace-only version strings in template info objects and add CI-unconditional multi-service fixture tests
@gsmith85
gsmith85 force-pushed the feat/codegen-openapi branch from 3563d15 to 011f26c Compare October 26, 2026 16:35
@gsmith85

Copy link
Copy Markdown
Contributor Author

Thanks @jingyli and @vishkaty for the thorough reviews!

  • @jingyli: Rebased cleanly onto main (99acbc6) and addressed the multi-service servers mapping in 011f26c using per-service route binding/pruning and OAS 3.1 Path Item Object.servers when retained routes span distinct endpoints.
  • @vishkaty: Addressed all three items across e227844, e1de4ae, and 011f26c:
    1. info.version is seeded from the active REST service binding's version (falling back to "1.0.0") and merged additively so templates omitting info.version (or setting whitespace-only version) always emit a valid OAS 3.1 Info Object.
    2. Added assert_openapi_invariants structural checks (required info.title/info.version, parameter/operation/server invariants, component $ref closure) plus standalone CI fixtures in tests/fixtures/compose/ so multi-service and single-service OpenAPI generation run unconditionally in CI without requiring a sibling ../ucp/source checkout.
    3. Updated .github/workflows/ci.yml so PRs targeting non-main branches also execute full CI.

@jingyli jingyli 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.

Thanks @gsmith85 for the updates, approving this PR!

@gsmith85
gsmith85 merged commit 2ce04cc into main Oct 26, 2026
21 checks passed
@gsmith85
gsmith85 deleted the feat/codegen-openapi branch October 26, 2026 19:15
@vishkaty

Copy link
Copy Markdown
Contributor

Confirmed on main at 2ce04cc from a fresh clone: the control profile emits info.version from the binding and validates with openapi-spec-validator 0.9.0, cargo test --all-targets runs the compose fixture tests without a sibling checkout (447 passed), and the CI run on the merge commit is green. One input the trim guard does not cover, a template info.version of null or a number, is in #93 with the reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants