Skip to main content

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}