iota_rest_api/
openapi.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    collections::HashSet,
7    sync::{Arc, OnceLock},
8};
9
10use axum::{
11    Router,
12    body::Bytes,
13    extract::State,
14    handler::Handler,
15    http::Method,
16    response::Html,
17    routing::{MethodRouter, get},
18};
19use openapiv3::v3_1::{
20    Components, Header, Info, MediaType, OpenApi, Operation, Parameter, ParameterData, PathItem,
21    Paths, ReferenceOr, RequestBody, Response, SchemaObject, Tag,
22};
23use schemars::{JsonSchema, gen::SchemaGenerator};
24use tap::Pipe;
25
26pub trait ApiEndpoint<S> {
27    fn method(&self) -> Method;
28    fn path(&self) -> &'static str;
29    fn hidden(&self) -> bool {
30        false
31    }
32
33    fn operation(&self, _generator: &mut SchemaGenerator) -> Operation {
34        Operation::default()
35    }
36
37    fn handler(&self) -> RouteHandler<S>;
38}
39
40pub struct RouteHandler<S> {
41    method: axum::http::Method,
42    handler: MethodRouter<S>,
43}
44
45impl<S: Clone> RouteHandler<S> {
46    pub fn new<H, T>(method: axum::http::Method, handler: H) -> Self
47    where
48        H: Handler<T, S>,
49        T: 'static,
50        S: Send + Sync + 'static,
51    {
52        let handler = MethodRouter::new().on(method.clone().try_into().unwrap(), handler);
53
54        Self { method, handler }
55    }
56
57    pub fn method(&self) -> &axum::http::Method {
58        &self.method
59    }
60}
61
62pub struct Api<'a, S> {
63    endpoints: Vec<&'a dyn ApiEndpoint<S>>,
64    info: Info,
65}
66
67impl<'a, S> Api<'a, S> {
68    pub fn new(info: Info) -> Self {
69        Self {
70            endpoints: Vec::new(),
71            info,
72        }
73    }
74
75    pub fn register_endpoints<I: IntoIterator<Item = &'a dyn ApiEndpoint<S>>>(
76        &mut self,
77        endpoints: I,
78    ) {
79        self.endpoints.extend(endpoints);
80    }
81
82    pub fn to_router(&self) -> axum::Router<S>
83    where
84        S: Clone + Send + Sync + 'static,
85    {
86        let mut router = OpenApiDocument::new(self.openapi()).into_router();
87        for endpoint in &self.endpoints {
88            let handler = endpoint.handler();
89            assert_eq!(handler.method(), endpoint.method());
90
91            // we need to replace any path parameters wrapped in braces to be prefaced by a
92            // colon until axum updates matchit: https://github.com/tokio-rs/axum/pull/2645
93            let path = endpoint.path().replace('{', ":").replace('}', "");
94
95            router = router.route(&path, handler.handler);
96        }
97
98        router
99    }
100
101    pub fn openapi(&self) -> openapiv3::versioned::OpenApi {
102        self.gen_openapi(self.info.clone())
103    }
104
105    /// Internal routine for constructing the OpenAPI definition describing this
106    /// API in its JSON form.
107    fn gen_openapi(&self, info: Info) -> openapiv3::versioned::OpenApi {
108        let mut openapi = OpenApi {
109            info,
110            ..Default::default()
111        };
112
113        let settings = schemars::gen::SchemaSettings::draft07().with(|s| {
114            s.definitions_path = "#/components/schemas/".into();
115            s.option_add_null_type = false;
116        });
117        let mut generator = schemars::gen::SchemaGenerator::new(settings);
118        let mut tags = HashSet::new();
119
120        let paths = openapi
121            .paths
122            .get_or_insert(openapiv3::v3_1::Paths::default());
123
124        for endpoint in &self.endpoints {
125            // Skip hidden endpoints
126            if endpoint.hidden() {
127                continue;
128            }
129
130            Self::register_endpoint(*endpoint, &mut generator, paths, &mut tags);
131        }
132
133        // Add OpenApi routes themselves
134        let openapi_endpoints: [&dyn ApiEndpoint<_>; 3] =
135            [&OpenApiExplorer, &OpenApiJson, &OpenApiYaml];
136        for endpoint in openapi_endpoints {
137            Self::register_endpoint(endpoint, &mut generator, paths, &mut tags);
138        }
139
140        let components = &mut openapi.components.get_or_insert_with(Components::default);
141
142        // Add the schemas for which we generated references.
143        let schemas = &mut components.schemas;
144
145        generator
146            .into_root_schema_for::<()>()
147            .definitions
148            .into_iter()
149            .for_each(|(key, schema)| {
150                let schema_object = SchemaObject {
151                    json_schema: schema,
152                    external_docs: None,
153                    example: None,
154                };
155                schemas.insert(key, schema_object);
156            });
157
158        openapi.tags = tags
159            .into_iter()
160            .map(|tag| Tag {
161                name: tag,
162                ..Default::default()
163            })
164            .collect();
165        // Sort the tags for stability
166        openapi.tags.sort_by(|a, b| a.name.cmp(&b.name));
167
168        openapi.servers = vec![openapiv3::v3_1::Server {
169            url: "/api/v1".into(),
170            ..Default::default()
171        }];
172
173        openapiv3::versioned::OpenApi::Version31(openapi)
174    }
175
176    fn register_endpoint<S2>(
177        endpoint: &dyn ApiEndpoint<S2>,
178        generator: &mut schemars::gen::SchemaGenerator,
179        paths: &mut Paths,
180        tags: &mut HashSet<String>,
181    ) {
182        let path = paths
183            .paths
184            .entry(endpoint.path().to_owned())
185            .or_insert(ReferenceOr::Item(PathItem::default()));
186
187        let pathitem = match path {
188            openapiv3::v3_1::ReferenceOr::Item(ref mut item) => item,
189            _ => panic!("reference not expected"),
190        };
191
192        let method_ref = match endpoint.method() {
193            Method::DELETE => &mut pathitem.delete,
194            Method::GET => &mut pathitem.get,
195            Method::HEAD => &mut pathitem.head,
196            Method::OPTIONS => &mut pathitem.options,
197            Method::PATCH => &mut pathitem.patch,
198            Method::POST => &mut pathitem.post,
199            Method::PUT => &mut pathitem.put,
200            Method::TRACE => &mut pathitem.trace,
201            other => panic!("unexpected method `{}`", other),
202        };
203
204        let operation = endpoint.operation(generator);
205
206        // Collect tags defined by this operation
207        tags.extend(operation.tags.clone());
208
209        method_ref.replace(operation);
210    }
211}
212
213pub struct OpenApiDocument {
214    openapi: openapiv3::versioned::OpenApi,
215    json: OnceLock<Bytes>,
216    yaml: OnceLock<Bytes>,
217    ui: &'static str,
218}
219
220impl OpenApiDocument {
221    pub fn new(openapi: openapiv3::versioned::OpenApi) -> Self {
222        const OPENAPI_UI: &str = include_str!("../openapi/elements.html");
223        // const OPENAPI_UI: &str = include_str!("../openapi/swagger.html");
224
225        Self {
226            openapi,
227            json: OnceLock::new(),
228            yaml: OnceLock::new(),
229            ui: OPENAPI_UI,
230        }
231    }
232
233    fn openapi(&self) -> &openapiv3::versioned::OpenApi {
234        &self.openapi
235    }
236
237    fn json(&self) -> Bytes {
238        self.json
239            .get_or_init(|| {
240                self.openapi()
241                    .pipe(serde_json::to_string_pretty)
242                    .unwrap()
243                    .pipe(Bytes::from)
244            })
245            .clone()
246    }
247
248    fn yaml(&self) -> Bytes {
249        self.yaml
250            .get_or_init(|| {
251                self.openapi()
252                    .pipe(serde_yaml::to_string)
253                    .unwrap()
254                    .pipe(Bytes::from)
255            })
256            .clone()
257    }
258
259    fn ui(&self) -> &'static str {
260        self.ui
261    }
262
263    pub fn into_router<S>(self) -> Router<S> {
264        Router::new()
265            .route("/openapi", get(openapi_ui))
266            .route("/openapi.json", get(openapi_json))
267            .route("/openapi.yaml", get(openapi_yaml))
268            .with_state(Arc::new(self))
269    }
270}
271
272pub struct OpenApiJson;
273
274impl ApiEndpoint<Arc<OpenApiDocument>> for OpenApiJson {
275    fn method(&self) -> axum::http::Method {
276        axum::http::Method::GET
277    }
278
279    fn path(&self) -> &'static str {
280        "/openapi.json"
281    }
282
283    fn operation(
284        &self,
285        _generator: &mut schemars::gen::SchemaGenerator,
286    ) -> openapiv3::v3_1::Operation {
287        OperationBuilder::new()
288            .tag("OpenApi")
289            .operation_id("openapi.json")
290            .response(
291                200,
292                ResponseBuilder::new()
293                    .content(mime::APPLICATION_JSON.as_ref(), MediaType::default())
294                    .build(),
295            )
296            .build()
297    }
298
299    fn handler(&self) -> RouteHandler<Arc<OpenApiDocument>> {
300        RouteHandler::new(self.method(), openapi_json)
301    }
302}
303
304pub struct OpenApiYaml;
305
306impl ApiEndpoint<Arc<OpenApiDocument>> for OpenApiYaml {
307    fn method(&self) -> axum::http::Method {
308        axum::http::Method::GET
309    }
310
311    fn path(&self) -> &'static str {
312        "/openapi.yaml"
313    }
314
315    fn operation(
316        &self,
317        _generator: &mut schemars::gen::SchemaGenerator,
318    ) -> openapiv3::v3_1::Operation {
319        OperationBuilder::new()
320            .tag("OpenApi")
321            .operation_id("openapi.yaml")
322            .response(
323                200,
324                ResponseBuilder::new()
325                    .content(mime::TEXT_PLAIN_UTF_8.as_ref(), MediaType::default())
326                    .build(),
327            )
328            .build()
329    }
330
331    fn handler(&self) -> RouteHandler<Arc<OpenApiDocument>> {
332        RouteHandler::new(self.method(), openapi_yaml)
333    }
334}
335
336pub struct OpenApiExplorer;
337
338impl ApiEndpoint<Arc<OpenApiDocument>> for OpenApiExplorer {
339    fn method(&self) -> axum::http::Method {
340        axum::http::Method::GET
341    }
342
343    fn path(&self) -> &'static str {
344        "/openapi"
345    }
346
347    fn operation(
348        &self,
349        _generator: &mut schemars::gen::SchemaGenerator,
350    ) -> openapiv3::v3_1::Operation {
351        OperationBuilder::new()
352            .tag("OpenApi")
353            .operation_id("OpenApi Explorer")
354            .response(
355                200,
356                ResponseBuilder::new()
357                    .content(mime::TEXT_HTML_UTF_8.as_ref(), MediaType::default())
358                    .build(),
359            )
360            .build()
361    }
362
363    fn handler(&self) -> RouteHandler<Arc<OpenApiDocument>> {
364        RouteHandler::new(self.method(), openapi_ui)
365    }
366}
367
368async fn openapi_json(
369    State(document): State<Arc<OpenApiDocument>>,
370) -> impl axum::response::IntoResponse {
371    (
372        [(
373            axum::http::header::CONTENT_TYPE,
374            axum::http::HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()),
375        )],
376        document.json(),
377    )
378}
379
380async fn openapi_yaml(
381    State(document): State<Arc<OpenApiDocument>>,
382) -> impl axum::response::IntoResponse {
383    (
384        [(
385            axum::http::header::CONTENT_TYPE,
386            axum::http::HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
387        )],
388        document.yaml(),
389    )
390}
391
392async fn openapi_ui(State(document): State<Arc<OpenApiDocument>>) -> Html<&'static str> {
393    Html(document.ui())
394}
395
396fn path_parameter<T: JsonSchema>(
397    name: &str,
398    generator: &mut SchemaGenerator,
399) -> ReferenceOr<Parameter> {
400    let schema_object = SchemaObject {
401        json_schema: generator.subschema_for::<T>(),
402        external_docs: None,
403        example: None,
404    };
405
406    let parameter_data = ParameterData {
407        name: name.into(),
408        required: true,
409        format: openapiv3::v3_1::ParameterSchemaOrContent::Schema(schema_object),
410        description: None,
411        deprecated: None,
412        example: None,
413        examples: Default::default(),
414        explode: None,
415        extensions: Default::default(),
416    };
417
418    ReferenceOr::Item(Parameter::Path {
419        parameter_data,
420        style: openapiv3::v3_1::PathStyle::Simple,
421    })
422}
423
424fn query_parameters<T: JsonSchema>(generator: &mut SchemaGenerator) -> Vec<ReferenceOr<Parameter>> {
425    let mut params = Vec::new();
426
427    let schema = generator.root_schema_for::<T>().schema;
428
429    let Some(object) = &schema.object else {
430        return params;
431    };
432
433    for (name, schema) in &object.properties {
434        let s = schema.clone().into_object();
435
436        params.push(ReferenceOr::Item(Parameter::Query {
437            parameter_data: ParameterData {
438                name: name.clone(),
439                description: s.metadata.as_ref().and_then(|m| m.description.clone()),
440                required: object.required.contains(name),
441                format: openapiv3::v3_1::ParameterSchemaOrContent::Schema(SchemaObject {
442                    json_schema: s.into(),
443                    example: None,
444                    external_docs: None,
445                }),
446                extensions: Default::default(),
447                deprecated: None,
448                example: None,
449                examples: Default::default(),
450                explode: None,
451            },
452            allow_reserved: false,
453            style: openapiv3::v3_1::QueryStyle::Form,
454            allow_empty_value: None,
455        }));
456    }
457
458    params
459}
460
461#[derive(Default)]
462pub struct OperationBuilder {
463    inner: Operation,
464}
465
466impl OperationBuilder {
467    pub fn new() -> Self {
468        Self {
469            inner: Default::default(),
470        }
471    }
472
473    pub fn build(&mut self) -> Operation {
474        self.inner.clone()
475    }
476
477    pub fn tag<T: Into<String>>(&mut self, tag: T) -> &mut Self {
478        self.inner.tags.push(tag.into());
479        self
480    }
481
482    pub fn summary<T: Into<String>>(&mut self, summary: T) -> &mut Self {
483        self.inner.summary = Some(summary.into());
484        self
485    }
486
487    pub fn description<T: Into<String>>(&mut self, description: T) -> &mut Self {
488        self.inner.description = Some(description.into());
489        self
490    }
491
492    pub fn operation_id<T: Into<String>>(&mut self, operation_id: T) -> &mut Self {
493        self.inner.operation_id = Some(operation_id.into());
494        self
495    }
496
497    pub fn path_parameter<T: JsonSchema>(
498        &mut self,
499        name: &str,
500        generator: &mut SchemaGenerator,
501    ) -> &mut Self {
502        self.inner
503            .parameters
504            .push(path_parameter::<T>(name, generator));
505        self
506    }
507
508    pub fn query_parameters<T: JsonSchema>(
509        &mut self,
510        generator: &mut SchemaGenerator,
511    ) -> &mut Self {
512        self.inner
513            .parameters
514            .extend(query_parameters::<T>(generator));
515        self
516    }
517
518    pub fn response(&mut self, status_code: u16, response: Response) -> &mut Self {
519        let responses = self.inner.responses.get_or_insert(Default::default());
520        responses.responses.insert(
521            openapiv3::v3_1::StatusCode::Code(status_code),
522            ReferenceOr::Item(response),
523        );
524
525        self
526    }
527
528    pub fn request_body(&mut self, request_body: RequestBody) -> &mut Self {
529        self.inner.request_body = Some(ReferenceOr::Item(request_body));
530        self
531    }
532}
533
534#[derive(Default)]
535pub struct ResponseBuilder {
536    inner: Response,
537}
538
539impl ResponseBuilder {
540    pub fn new() -> Self {
541        Self {
542            inner: Default::default(),
543        }
544    }
545
546    pub fn build(&mut self) -> Response {
547        self.inner.clone()
548    }
549
550    pub fn header<T: JsonSchema>(
551        &mut self,
552        name: &str,
553        generator: &mut SchemaGenerator,
554    ) -> &mut Self {
555        let schema_object = SchemaObject {
556            json_schema: generator.subschema_for::<T>(),
557            external_docs: None,
558            example: None,
559        };
560
561        let header = ReferenceOr::Item(Header {
562            description: None,
563            style: Default::default(),
564            required: false,
565            deprecated: None,
566            format: openapiv3::v3_1::ParameterSchemaOrContent::Schema(schema_object),
567            example: None,
568            examples: Default::default(),
569            extensions: Default::default(),
570        });
571
572        self.inner.headers.insert(name.into(), header);
573        self
574    }
575
576    pub fn content<T: Into<String>>(
577        &mut self,
578        content_type: T,
579        media_type: MediaType,
580    ) -> &mut Self {
581        self.inner.content.insert(content_type.into(), media_type);
582        self
583    }
584
585    pub fn json_content<T: JsonSchema>(&mut self, generator: &mut SchemaGenerator) -> &mut Self {
586        let schema_object = SchemaObject {
587            json_schema: generator.subschema_for::<T>(),
588            external_docs: None,
589            example: None,
590        };
591        let media_type = MediaType {
592            schema: Some(schema_object),
593            ..Default::default()
594        };
595
596        self.content(mime::APPLICATION_JSON.as_ref(), media_type)
597    }
598
599    pub fn bcs_content(&mut self) -> &mut Self {
600        self.content(crate::APPLICATION_BCS, MediaType::default())
601    }
602
603    pub fn text_content(&mut self) -> &mut Self {
604        self.content(mime::TEXT_PLAIN_UTF_8.as_ref(), MediaType::default())
605    }
606}
607
608#[derive(Default)]
609pub struct RequestBodyBuilder {
610    inner: RequestBody,
611}
612
613impl RequestBodyBuilder {
614    pub fn new() -> Self {
615        Self {
616            inner: Default::default(),
617        }
618    }
619
620    pub fn build(&mut self) -> RequestBody {
621        self.inner.clone()
622    }
623
624    pub fn content<T: Into<String>>(
625        &mut self,
626        content_type: T,
627        media_type: MediaType,
628    ) -> &mut Self {
629        self.inner.content.insert(content_type.into(), media_type);
630        self
631    }
632
633    pub fn json_content<T: JsonSchema>(&mut self, generator: &mut SchemaGenerator) -> &mut Self {
634        let schema_object = SchemaObject {
635            json_schema: generator.subschema_for::<T>(),
636            external_docs: None,
637            example: None,
638        };
639        let media_type = MediaType {
640            schema: Some(schema_object),
641            ..Default::default()
642        };
643
644        self.content(mime::APPLICATION_JSON.as_ref(), media_type)
645    }
646
647    pub fn bcs_content(&mut self) -> &mut Self {
648        self.content(crate::APPLICATION_BCS, MediaType::default())
649    }
650}