openehr-its 0.0.128

openEHR ITS (Implementation Technology Specifications): canonical JSON + canonical XML serialization of the RM, the generated ITS-REST 1.1.0 API contract, and the OPT 1.4 and AOM2 archetype XML codecs
Documentation
// SPDX-FileCopyrightText: Cadasto B.V.
// SPDX-FileCopyrightText: openEHR Foundation
// SPDX-License-Identifier: Apache-2.0

//! The whole-API router and its fallbacks, and the runtime under the
//! generated per-group routers (`rest::generated::<group>::server::router`).
//!
//! [`router`] merges every group's router over one implementation and
//! finishes it with [`with_fallbacks`], which gives an unserved path a `404` and
//! an unserved method a `405` with `Allow`, both with the ITS-REST `Error` body.
//!
//! The generated handlers own everything the OpenAPI declares per operation
//! (which parameter sits where, its name and type, the body type, the
//! documented answers and their headers); this module owns what every handler
//! shares: reading a path capture, a query pair, a header and a body into a
//! typed value, refusing one that is missing or unparseable with a `400` that
//! names it, and writing an answer.
//!
//! A query parameter is read as the generated client writes it: one
//! `name=value` pair per scalar, one pair per array item, and one pair per
//! member of a form-exploded object (`style: form, explode: true`). Both the
//! name and the value are percent-decoded (RFC 3986 §2.1, §3.4), so a `+` and
//! `%2B` are both a literal plus.

use std::sync::Arc;

use axum::extract::RawPathParams;
use axum::extract::rejection::RawPathParamsRejection;
use axum::response::IntoResponse as _;
use http::header::{ALLOW, CONTENT_TYPE};
use http::{HeaderMap, HeaderValue, StatusCode};

use super::decode::PathValues;
use super::generated::admin::server::AdminApi;
use super::generated::definition::server::DefinitionApi;
use super::generated::demographic::server::DemographicApi;
use super::generated::ehr::server::EhrApi;
use super::generated::query::server::QueryApi;
use super::generated::system::server::SystemApi;
use super::generated::{admin, definition, demographic, ehr, query, system};
use super::routes::{Lookup, lookup};
use super::runtime::{
    ApiError, HeaderError, Refusal, ResponseHeaders, error_response, merge_headers,
};

/// The canonical JSON media type, the only one the typed router reads and
/// writes.
const CANONICAL_JSON: &str = "application/json";

/// The path captures axum extracted for a route, renamed through `names`
/// (`(capture, parameter)` pairs: the generated handler's positional captures
/// `p2`, `p3`) to the operation's parameter names.
///
/// # Errors
/// Returns [`ApiError::BadRequest`] when a capture does not decode to UTF-8
/// text.
pub(crate) fn path_captures(
    captured: Result<RawPathParams, RawPathParamsRejection>,
    names: &[(&str, &'static str)],
) -> Result<PathValues, ApiError> {
    let captured = captured.map_err(|rejection| {
        ApiError::BadRequest(format!("a path segment is not valid: {rejection}"))
    })?;
    Ok(PathValues::new(
        captured
            .iter()
            .filter_map(|(capture, value)| {
                names
                    .iter()
                    .find(|(c, _)| *c == capture)
                    .map(|(_, name)| (*name, value.to_owned()))
            })
            .collect(),
    ))
}

/// One answer under construction: a status, headers and a body.
pub(crate) struct Reply {
    status: StatusCode,
    headers: HeaderMap,
    body: Vec<u8>,
}

impl Reply {
    /// An answer with `status`, no headers and no body.
    pub(crate) fn new(status: StatusCode) -> Self {
        Self {
            status,
            headers: HeaderMap::new(),
            body: Vec::new(),
        }
    }

    /// Sets `body` serialized as canonical JSON, with `Content-Type:
    /// application/json` (`Resources.md` §JSON Format: "Proper header
    /// `Content-Type: application/json` MUST be present in the response").
    ///
    /// # Errors
    /// Returns [`ApiError::Internal`] when `body` does not serialize.
    pub(crate) fn json<B: serde::Serialize + ?Sized>(&mut self, body: &B) -> Result<(), ApiError> {
        self.body = match serde_json::to_vec(body) {
            Ok(bytes) => bytes,
            Err(_) => {
                return Err(ApiError::Internal(
                    "the answer body does not serialize".to_owned(),
                ));
            }
        };
        self.headers
            .insert(CONTENT_TYPE, HeaderValue::from_static(CANONICAL_JSON));
        Ok(())
    }

    /// Sets `body` as given; its `Content-Type` is a header of the answer.
    pub(crate) fn raw(&mut self, body: Vec<u8>) {
        self.body = body;
    }

    /// Sets the answer's declared `headers`, each replacing a same-named
    /// header set before.
    ///
    /// # Errors
    /// Returns a [`HeaderError`] when a value is not legal on the wire.
    pub(crate) fn headers(&mut self, headers: impl ResponseHeaders) -> Result<(), HeaderError> {
        merge_headers(&mut self.headers, headers.into_header_map()?);
        Ok(())
    }

    /// The finished axum response.
    pub(crate) fn finish(self) -> axum::response::Response {
        let mut response = axum::response::Response::new(axum::body::Body::from(self.body));
        *response.status_mut() = self.status;
        *response.headers_mut() = self.headers;
        response
    }
}

/// The response of one served request: the answer, or the refusal rendered as
/// the ITS-REST `Error` body with its headers.
pub(crate) fn respond(
    served: Result<axum::response::Response, Refusal>,
) -> axum::response::Response {
    match served {
        Ok(response) => response,
        Err(refusal) => refusal.into_response(),
    }
}

/// The axum router serving every operation of every API group over `api`,
/// finished with the fallbacks of [`with_fallbacks`].
///
/// It merges the six generated group routers
/// (`rest::generated::<group>::server::router`); mount it at the API base with
/// `axum::Router::nest` (`Router::new().nest("/openehr/v1", router(api))`). A
/// server that implements only some groups merges their routers itself and
/// finishes them with [`with_fallbacks`].
pub fn router<S>(api: Arc<S>) -> axum::Router
where
    S: AdminApi + DefinitionApi + DemographicApi + EhrApi + QueryApi + SystemApi,
    S: Send + Sync + 'static,
{
    with_fallbacks(
        admin::server::router(Arc::clone(&api))
            .merge(definition::server::router(Arc::clone(&api)))
            .merge(demographic::server::router(Arc::clone(&api)))
            .merge(ehr::server::router(Arc::clone(&api)))
            .merge(query::server::router(Arc::clone(&api)))
            .merge(system::server::router(api)),
    )
}

/// Finishes a router of merged group routers with the ITS-REST fallbacks.
///
/// A path no route serves answers `404 Not Found`, and a served path under a
/// method none of its routes takes answers `405 Method Not Allowed` with an
/// `Allow` header listing the methods the route tables declare for that path
/// ([`super::routes::lookup`]); both carry the ITS-REST `Error` body
/// (`ITS-REST/specifications/docs/overview/Requests_and_responses.md` §HTTP
/// status codes lists both statuses).
///
/// Call it once, after the last merge: axum applies the `405` fallback to the
/// routes registered so far, and refuses (panics on) a merge of two routers
/// that both carry a fallback, which is why the group routers carry none. The
/// fallbacks read the request path as the router sees it, so mount the
/// finished router at the API base with `axum::Router::nest`, or serve it at
/// the root of a server whose API base is `/`.
pub fn with_fallbacks(router: axum::Router) -> axum::Router {
    router
        .fallback(not_found)
        .method_not_allowed_fallback(method_not_allowed)
}

/// The `404` of a path no route serves.
async fn not_found(uri: http::Uri) -> axum::response::Response {
    error_response(
        StatusCode::NOT_FOUND,
        format!("no ITS-REST operation has the path `{}`", uri.path()),
        Vec::new(),
    )
}

/// The `405` of a served path under a method none of its routes takes.
async fn method_not_allowed(method: http::Method, uri: http::Uri) -> axum::response::Response {
    let mut response = error_response(
        StatusCode::METHOD_NOT_ALLOWED,
        format!("the operations at `{}` do not take {method}", uri.path()),
        Vec::new(),
    );
    // A path the caller routed beyond the route tables keeps the `Allow`
    // header axum computes from its own routes.
    if let Lookup::MethodNotAllowed { allowed } = lookup(&method, uri.path())
        && let Ok(value) = HeaderValue::from_str(&allowed.join(", "))
    {
        response.headers_mut().insert(ALLOW, value);
    }
    response
}