Skip to main content

better_fetch/openapi/
builder.rs

1//! [`OpenApiBuilder`] — builds [`OpenApiDocument`] from a [`SchemaRegistry`](crate::schema::SchemaRegistry).
2
3use indexmap::IndexMap;
4
5use crate::schema::{EndpointSchema, SchemaRegistry};
6
7use super::convert::{
8    is_null_schema, operation_id, parameters_from_schema, path_param_names, schema_ref,
9    to_openapi_path, SchemaCatalog,
10};
11use super::document::{
12    OpenApiComponents, OpenApiDocument, OpenApiInfo, OpenApiMediaType, OpenApiOperation,
13    OpenApiRequestBody, OpenApiResponse, OpenApiResponses, OpenApiServer,
14};
15
16const JSON_CONTENT: &str = "application/json";
17
18/// Builds an OpenAPI 3.0 document from a [`SchemaRegistry`].
19#[derive(Debug, Default)]
20pub struct OpenApiBuilder {
21    title: String,
22    version: String,
23    description: Option<String>,
24    servers: Vec<OpenApiServer>,
25}
26
27impl OpenApiBuilder {
28    /// Creates a builder with default title and version.
29    pub fn new() -> Self {
30        Self {
31            title: "API".into(),
32            version: "0.1.0".into(),
33            description: None,
34            servers: Vec::new(),
35        }
36    }
37
38    /// Sets the API title in `info.title`.
39    pub fn title(mut self, title: impl Into<String>) -> Self {
40        self.title = title.into();
41        self
42    }
43
44    /// Sets `info.version`.
45    pub fn version(mut self, version: impl Into<String>) -> Self {
46        self.version = version.into();
47        self
48    }
49
50    /// Sets `info.description`.
51    pub fn description(mut self, description: impl Into<String>) -> Self {
52        self.description = Some(description.into());
53        self
54    }
55
56    /// Adds a server URL.
57    pub fn server(mut self, url: impl Into<String>) -> Self {
58        self.servers.push(OpenApiServer {
59            url: url.into(),
60            description: None,
61        });
62        self
63    }
64
65    /// Adds a server URL with description.
66    pub fn server_with_description(
67        mut self,
68        url: impl Into<String>,
69        description: impl Into<String>,
70    ) -> Self {
71        self.servers.push(OpenApiServer {
72            url: url.into(),
73            description: Some(description.into()),
74        });
75        self
76    }
77
78    /// Builds an OpenAPI 3.0 document from a [`SchemaRegistry`](crate::schema::SchemaRegistry).
79    ///
80    /// See the `openapi_export` example in the repository for a full workflow.
81    pub fn from_registry(&self, registry: &SchemaRegistry) -> OpenApiDocument {
82        let mut catalog = SchemaCatalog::default();
83        let mut paths: IndexMap<String, IndexMap<String, OpenApiOperation>> = IndexMap::new();
84
85        for entry in registry.entries() {
86            let operation = build_operation(entry, &mut catalog);
87            let openapi_path = to_openapi_path(&entry.path);
88            let method = entry.method.as_str().to_ascii_lowercase();
89            paths
90                .entry(openapi_path)
91                .or_default()
92                .insert(method, operation);
93        }
94
95        let components = if catalog.schemas.is_empty() {
96            None
97        } else {
98            Some(OpenApiComponents {
99                schemas: catalog.schemas,
100            })
101        };
102
103        OpenApiDocument {
104            openapi: "3.0.3".into(),
105            info: OpenApiInfo {
106                title: self.title.clone(),
107                version: self.version.clone(),
108                description: self.description.clone(),
109            },
110            servers: if self.servers.is_empty() {
111                None
112            } else {
113                Some(self.servers.clone())
114            },
115            paths,
116            components,
117        }
118    }
119}
120
121fn build_operation(entry: &EndpointSchema, catalog: &mut SchemaCatalog) -> OpenApiOperation {
122    let path_names = path_param_names(&entry.path);
123    let prefix = operation_id(&entry.method, &entry.path);
124
125    let mut parameters = Vec::new();
126    if let Some(params_schema) = &entry.params_schema {
127        parameters.extend(parameters_from_schema(
128            params_schema,
129            "path",
130            &path_names,
131            catalog,
132            &format!("{prefix}Path"),
133        ));
134    }
135    if let Some(query_schema) = &entry.query_schema {
136        parameters.extend(parameters_from_schema(
137            query_schema,
138            "query",
139            &path_names,
140            catalog,
141            &format!("{prefix}Query"),
142        ));
143    }
144
145    let request_body = entry.request_schema.as_ref().and_then(|schema| {
146        if is_null_schema(schema) {
147            return None;
148        }
149        let ref_path = catalog.register(&format!("{prefix}Request"), schema)?;
150        Some(OpenApiRequestBody {
151            description: Some("Request body".into()),
152            required: true,
153            content: json_content(schema_ref(ref_path)),
154        })
155    });
156
157    let mut response_map: IndexMap<String, OpenApiResponse> = IndexMap::new();
158    if let Some(response_schema) = &entry.response_schema {
159        if !is_null_schema(response_schema) {
160            if let Some(ref_path) = catalog.register(&format!("{prefix}Response"), response_schema)
161            {
162                response_map.insert(
163                    "200".into(),
164                    OpenApiResponse {
165                        description: "Success".into(),
166                        content: Some(json_content(schema_ref(ref_path))),
167                    },
168                );
169            }
170        }
171    }
172    if response_map.is_empty() {
173        response_map.insert(
174            "200".into(),
175            OpenApiResponse {
176                description: "Success".into(),
177                content: None,
178            },
179        );
180    }
181
182    let summary = format!("{} {}", entry.method, entry.path);
183
184    OpenApiOperation {
185        summary: Some(summary),
186        description: None,
187        operation_id: Some(prefix),
188        parameters,
189        request_body,
190        responses: OpenApiResponses {
191            statuses: response_map,
192        },
193    }
194}
195
196fn json_content(schema: super::document::OpenApiSchemaRef) -> IndexMap<String, OpenApiMediaType> {
197    let mut map = IndexMap::new();
198    map.insert(JSON_CONTENT.into(), OpenApiMediaType { schema });
199    map
200}