Skip to main content

edda_server/
openapi.rs

1//! What every service's `OpenAPI` description shares: the token routes need,
2//! and the error responses any route may give.
3
4use utoipa::{
5    Modify, PartialSchema, ToSchema,
6    openapi::{
7        OpenApi, Ref, RefOr, ResponseBuilder, SecurityRequirement,
8        content::Content,
9        path::Operation,
10        security::{Http, HttpAuthScheme, SecurityScheme},
11    },
12};
13
14/// An error, from any route: `{"error": "..."}`.
15#[derive(ToSchema)]
16pub struct ErrorBody {
17    /// What went wrong, for people.
18    pub error: String,
19}
20
21/// The name of the token in [`Conventions`]' security scheme.
22pub const TOKEN: &str = "token";
23
24/// Applies the conventions every service keeps to its description: a
25/// route needs a token unless it says otherwise (`security(())`), and then
26/// may answer `401`; any route may answer `429`, over the limits; and
27/// operations are named after their tag and handler (`combats.list`), as
28/// handlers' names repeat.
29pub struct Conventions;
30
31impl Modify for Conventions {
32    fn modify(&self, openapi: &mut OpenApi) {
33        let components = openapi.components.get_or_insert_with(Default::default);
34        components.add_security_scheme(
35            TOKEN,
36            SecurityScheme::Http(
37                Http::builder()
38                    .scheme(HttpAuthScheme::Bearer)
39                    .bearer_format("PASETO v4.public")
40                    .description(Some(
41                        "From the auth service's `POST /login`; valid for 15 minutes.",
42                    ))
43                    .build(),
44            ),
45        );
46        components
47            .schemas
48            .insert("ErrorBody".into(), ErrorBody::schema());
49        for item in openapi.paths.paths.values_mut() {
50            for operation in [
51                &mut item.get,
52                &mut item.put,
53                &mut item.post,
54                &mut item.delete,
55            ]
56            .into_iter()
57            .flatten()
58            {
59                conventions(operation);
60            }
61        }
62    }
63}
64
65fn conventions(operation: &mut Operation) {
66    if let (Some(id), Some([tag, ..])) = (&operation.operation_id, operation.tags.as_deref())
67        && id != tag
68    {
69        operation.operation_id = Some(format!("{tag}.{id}"));
70    }
71    if operation.security.is_none() {
72        operation.security = Some(vec![SecurityRequirement::new::<_, [&str; 0], &str>(
73            TOKEN,
74            [],
75        )]);
76        add_error(operation, "401", "No token, or not a valid one.");
77    }
78    add_error(
79        operation,
80        "429",
81        "Too many requests from this address; `Retry-After` says when to try again.",
82    );
83}
84
85/// An error response, unless the route describes that status itself.
86pub fn add_error(operation: &mut Operation, status: &str, description: &str) {
87    operation
88        .responses
89        .responses
90        .entry(status.into())
91        .or_insert_with(|| error(description));
92}
93
94/// An error response with `description`.
95pub fn error(description: &str) -> RefOr<utoipa::openapi::Response> {
96    ResponseBuilder::new()
97        .description(description)
98        .content(
99            "application/json",
100            Content::new(Some(Ref::from_schema_name(ErrorBody::name()))),
101        )
102        .into()
103}