golem_schema/schema/render/openapi.rs
1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Thin OpenAPI 3.1 adaptor over [`super::json_schema`].
16//!
17//! Re-emits the same per-node shape but reroutes JSON-Schema's `$defs`
18//! into OpenAPI's `components/schemas` (and rewrites the corresponding
19//! `$ref` pointers). Per-branch union schemas (synthesised by
20//! [`super::json_schema::add_union_branch_defs`] under tag-derived keys
21//! resolved via [`super::json_schema::BranchNameTable`]) ship as
22//! additional component schemas so discriminator mappings resolve.
23
24use crate::schema::graph::SchemaGraph;
25use crate::schema::render::json_schema::{
26 JsonSchemaConfig, add_union_branch_defs, build_branch_name_table, render_defs, render_type,
27};
28use crate::schema::schema_type::SchemaType;
29use serde_json::{Map, Value};
30
31/// Canonical trusted JSON Schema renderer configuration used by
32/// [`to_openapi_components`], without the `$schema` draft marker (OpenAPI does
33/// not accept it).
34const OPENAPI_CONFIG: JsonSchemaConfig = JsonSchemaConfig::WITHOUT_DRAFT_MARKER;
35
36/// Render `(graph, ty)` to an OpenAPI 3.1 schema bundle.
37///
38/// The returned object has shape:
39///
40/// ```json
41/// {
42/// "components": { "schemas": { "<TypeId>": {…}, … } },
43/// "root": { … the root schema … }
44/// }
45/// ```
46///
47/// `$ref` pointers inside the schemas point at
48/// `#/components/schemas/<TypeId>` (rewritten from `#/$defs/<TypeId>`).
49/// OpenAPI does not accept the JSON Schema `$schema` keyword, so it is
50/// never emitted here.
51pub fn to_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value {
52 to_openapi_components_with_config(graph, ty, OPENAPI_CONFIG, None)
53}
54
55/// Render a schema bundle for untrusted external input.
56///
57/// Host-managed capability leaves are unsatisfiable, while their type metadata
58/// remains present in the surrounding schema structure.
59pub fn to_external_input_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value {
60 to_openapi_components_with_config(graph, ty, JsonSchemaConfig::EXTERNAL_INPUT, Some("Input_"))
61}
62
63/// Render a schema bundle for externally visible output.
64///
65/// Host-managed capability leaves describe only their redacted placeholders.
66pub fn to_external_output_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value {
67 to_openapi_components_with_config(
68 graph,
69 ty,
70 JsonSchemaConfig::EXTERNAL_OUTPUT,
71 Some("Output_"),
72 )
73}
74
75fn to_openapi_components_with_config(
76 graph: &SchemaGraph,
77 ty: &SchemaType,
78 config: JsonSchemaConfig,
79 component_prefix: Option<&str>,
80) -> Value {
81 // OpenAPI renders the canonical structural form; only ref-rewriting and
82 // `$schema` omission differ, both handled below.
83 let table = build_branch_name_table(graph, ty);
84 let root = rewrite_refs(
85 render_type(graph, ty, true, &table, config),
86 component_prefix,
87 );
88 let mut defs = render_defs(graph, &table, config);
89 add_union_branch_defs(graph, ty, &mut defs, &table, config);
90 // `$defs` map keys are raw per RFC 6901 §4: the JSON Pointer token in a
91 // `$ref` is the *escaped* form of the resolved object member name.
92 // We preserve raw keys when moving `$defs` → `components.schemas` so
93 // that an escaped `#/components/schemas/<token>` pointer resolves
94 // back to the raw key the same way it did under `$defs`.
95 //
96 // Note: this is JSON-Pointer-correct, but OpenAPI 3.1 additionally
97 // constrains component-name characters to `[A-Za-z0-9._-]`. TypeIds
98 // containing `/`, `~`, or other characters outside that set will
99 // produce technically-invalid component names. Adopting an
100 // OpenAPI-safe component-name registry is tracked as a follow-up;
101 // typical TypeIds never trigger this.
102 let schemas: Map<String, Value> = defs
103 .into_iter()
104 .map(|(key, value)| {
105 (
106 format!("{}{key}", component_prefix.unwrap_or_default()),
107 rewrite_refs(value, component_prefix),
108 )
109 })
110 .collect();
111
112 let mut out = Map::new();
113 out.insert(
114 "components".to_string(),
115 Value::Object({
116 let mut wrapper = Map::new();
117 wrapper.insert("schemas".to_string(), Value::Object(schemas));
118 wrapper
119 }),
120 );
121 out.insert("root".to_string(), root);
122 Value::Object(out)
123}
124
125fn rewrite_refs(mut v: Value, component_prefix: Option<&str>) -> Value {
126 rewrite_refs_in_place(&mut v, component_prefix);
127 v
128}
129
130fn rewrite_refs_in_place(v: &mut Value, component_prefix: Option<&str>) {
131 match v {
132 Value::Object(map) => {
133 // Rewrite both `$ref` pointers and discriminator-mapping
134 // targets.
135 if let Some(Value::String(ptr)) = map.get_mut("$ref")
136 && let Some(rest) = ptr.strip_prefix("#/$defs/")
137 {
138 *ptr = format!(
139 "#/components/schemas/{}{rest}",
140 component_prefix.unwrap_or_default()
141 );
142 }
143 if let Some(Value::Object(disc)) = map.get_mut("discriminator")
144 && let Some(Value::Object(mapping)) = disc.get_mut("mapping")
145 {
146 for value in mapping.values_mut() {
147 if let Value::String(s) = value
148 && let Some(rest) = s.strip_prefix("#/$defs/")
149 {
150 *s = format!(
151 "#/components/schemas/{}{rest}",
152 component_prefix.unwrap_or_default()
153 );
154 }
155 }
156 }
157 for value in map.values_mut() {
158 rewrite_refs_in_place(value, component_prefix);
159 }
160 }
161 Value::Array(arr) => {
162 for item in arr {
163 rewrite_refs_in_place(item, component_prefix);
164 }
165 }
166 _ => {}
167 }
168}