martin 1.8.2

Blazing fast and lightweight tile server with PostGIS, MBTiles, and PMTiles support
Documentation
//! Experimental schema generation for the config file (JSON Schema, via `schemars`)
//! and the HTTP API (`OpenAPI`, via `utoipa`).

#![expect(
    clippy::needless_for_each,
    reason = "noise from inside utoipa's OpenApi derive expansion"
)]

use schemars::schema_for;
use utoipa::OpenApi;

use crate::config::file::Config;

/// JSON Schema for the on-disk Martin config (`config.yaml`).
///
/// Returns the schema as a [`serde_json::Value`] for easy serialisation.
#[must_use]
pub fn config_json_schema() -> serde_json::Value {
    let schema = schema_for!(Config);
    serde_json::to_value(&schema).expect("JSON Schema is always serialisable")
}

/// `OpenAPI` 3.1 spec for Martin's HTTP surface.
///
/// Covers every primary route Martin serves.
#[derive(OpenApi)]
#[openapi(
    info(
        title = "Martin",
        description = "Blazing-fast tile server with PostGIS, MBTiles, and PMTiles support.",
        license(name = "MIT OR Apache-2.0"),
    ),
    paths(
        crate::srv::get_health,
        crate::srv::get_catalog,
        crate::srv::get_source_info,
        crate::srv::get_tile,
        crate::srv::get_sprite_png,
        crate::srv::get_sprite_sdf_png,
        crate::srv::get_sprite_json,
        crate::srv::get_sprite_sdf_json,
        crate::srv::get_font,
        crate::srv::get_style_json,
    )
)]
pub struct MartinOpenApi;

/// Server-side style-rendering route. Lives in its own derive because it's
/// only compiled on linux + `rendering`, and we don't want `unstable-schemas`
/// to drag in the maplibre-native C++ build.
#[cfg(all(feature = "rendering", target_os = "linux"))]
#[derive(OpenApi)]
#[openapi(paths(crate::srv::get_style_rendered))]
struct MartinRenderingOpenApi;

/// `OpenAPI` document as JSON.
#[must_use]
pub fn openapi_spec() -> serde_json::Value {
    let openapi = MartinOpenApi::openapi();
    #[cfg(all(feature = "rendering", target_os = "linux"))]
    let openapi = openapi.merge_from(MartinRenderingOpenApi::openapi());
    serde_json::to_value(&openapi).expect("OpenAPI doc is always serialisable")
}

/// Annotated markdown rendering of [`config_json_schema`] suitable for
/// shipping as `generated_config.md` in the docs.
///
/// Walks the JSON Schema, resolves `$ref`s into `$defs`, and wraps each
/// top-level property of the root config in a single fenced YAML block as
///
/// ```yaml
/// # <description from the schema, one line per `#`>
/// <key>: <example or sane placeholder>
/// ```
#[must_use]
pub fn config_doc_yaml() -> String {
    let schema = config_json_schema();
    let mut out = String::new();
    out.push_str(
        "<!-- This file is auto-generated by `just gen-schemas`. \
         Don't edit it, edit the code's doc comments instead -->\n\n",
    );
    out.push_str("Schema: [`schemas/config.json`](https://github.com/maplibre/martin/blob/main/schemas/config.json).\n\n");
    out.push_str("```yaml title=\"config.yaml\"\n");
    // `# yaml-language-server: $schema=…` lets editors validate users' own
    // `config.yaml` against the schema.
    out.push_str(
        "# yaml-language-server: $schema=https://raw.githubusercontent.com/maplibre/martin/main/schemas/config.json\n",
    );
    let ctx = config_doc::Ctx::new(&schema);
    config_doc::render_object(&mut out, &schema, &ctx, 0);
    out.push_str("\n```\n");
    out
}

mod config_doc {
    use std::fmt::Write as _;

    use serde_json::Value;

    /// Resolved schema + helpers.
    pub(super) struct Ctx<'a> {
        defs: Option<&'a serde_json::Map<String, Value>>,
    }

    impl<'a> Ctx<'a> {
        pub(super) fn new(root: &'a Value) -> Self {
            let defs = root.get("$defs").and_then(Value::as_object);
            Self { defs }
        }

        /// If `schema` is a `$ref`, follow it into `$defs` once and return the
        /// resolved schema; otherwise return the original.
        pub(super) fn resolve(&self, schema: &'a Value) -> &'a Value {
            let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
                return schema;
            };
            // Refs look like `#/$defs/Foo`. Pull the trailing segment.
            let Some(name) = reference.rsplit('/').next() else {
                return schema;
            };
            self.defs.and_then(|d| d.get(name)).map_or(schema, |v| v)
        }
    }

    pub(super) fn render_object(out: &mut String, schema: &Value, ctx: &Ctx, indent: usize) {
        let resolved = ctx.resolve(schema);
        let Some(properties) = resolved.get("properties").and_then(Value::as_object) else {
            return;
        };
        let mut first = true;
        for (key, prop) in properties {
            if !first {
                out.push('\n');
            }
            first = false;
            // Use the description from the property as it appears in the parent —
            // not the description on the resolved `$defs/<Type>` body. Schema-only
            // proxy types like `GlobalCacheConfigShape` carry rustdoc that is
            // about the proxy mechanism, not about the field, and it shouldn't
            // bleed into user-facing config docs.
            if let Some(desc) = prop.get("description").and_then(Value::as_str) {
                emit_comment(out, desc, indent);
            }
            push_indent(out, indent);
            out.push_str(key);
            out.push(':');
            render_value(out, prop, ctx, indent);
        }
    }

    fn emit_comment(out: &mut String, desc: &str, indent: usize) {
        // Rustdoc requires escaping `[` / `]` in prose to avoid intra-doc-link
        // ambiguity (`\[default: 512\]`). Those backslashes shouldn't bleed
        // into user-facing YAML, so strip them on the way out.
        for line in desc.lines() {
            push_indent(out, indent);
            if line.is_empty() {
                out.push('#');
            } else {
                out.push_str("# ");
                push_unescaped(out, line);
            }
            out.push('\n');
        }
    }

    fn push_unescaped(out: &mut String, line: &str) {
        let mut chars = line.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '\\'
                && let Some(&next) = chars.peek()
                && (next == '[' || next == ']')
            {
                out.push(next);
                chars.next();
            } else {
                out.push(c);
            }
        }
    }

    fn render_value(out: &mut String, schema: &Value, ctx: &Ctx, indent: usize) {
        let schema = ctx.resolve(schema);

        // 1. Prefer an explicit example.
        if let Some(example) = first_example(schema) {
            emit_inline_or_block(out, example, indent);
            return;
        }

        // 2. Fall back to a default if the schema declares one.
        if let Some(default) = schema.get("default")
            && !default.is_null()
        {
            emit_inline_or_block(out, default, indent);
            return;
        }

        // 3a. Inline enum form (`enum: [...]`, e.g. `PreferredEncoding`):
        //     emit the first allowed value.
        if let Some(values) = schema.get("enum").and_then(Value::as_array)
            && let Some(first) = values.first()
        {
            emit_inline_or_block(out, first, indent);
            return;
        }
        // 3b. `oneOf` of `const` variants (e.g. `OnInvalid`, `BoundsCalcType`):
        //     emit the first `const` so the example shows a concrete option
        //     rather than `""`.
        if let Some(variants) = schema.get("oneOf").and_then(Value::as_array)
            && let Some(constant) = variants
                .iter()
                .find_map(|v| ctx.resolve(v).get("const").cloned())
        {
            emit_inline_or_block(out, &constant, indent);
            return;
        }

        // 4. Composite types — pick the most informative variant rather than
        //    the first one. An `anyOf` like `[null, string, array, object]`
        //    should render as the object shape.
        if let Some(variants) = schema.get("anyOf").or_else(|| schema.get("oneOf"))
            && let Some(variants) = variants.as_array()
        {
            let primary = pick_primary_variant(variants, ctx);
            render_value(out, primary, ctx, indent);
            return;
        }

        // 5. Type-driven placeholders. Optional scalars (`type: [..., "null"]`)
        //    render as `null` rather than `""`/`0`/`false` — matches "no
        //    value" semantics and won't accidentally validate as a real value.
        let nullable = is_nullable(schema);
        match primary_type(schema).as_deref() {
            Some("object") => {
                if schema.get("properties").is_some() {
                    out.push('\n');
                    render_object(out, schema, ctx, indent + 1);
                } else {
                    out.push_str(" {}");
                }
            }
            Some("array") => out.push_str(" []"),
            _ if nullable => out.push_str(" null"),
            Some("string") => out.push_str(" \"\""),
            Some("integer" | "number") => out.push_str(" 0"),
            Some("boolean") => out.push_str(" false"),
            _ => out.push_str(" null"),
        }
    }

    /// `true` if the schema's `type` array includes `"null"`.
    fn is_nullable(schema: &Value) -> bool {
        match schema.get("type") {
            Some(Value::Array(a)) => a.iter().any(|v| v.as_str() == Some("null")),
            _ => false,
        }
    }

    /// Rank `anyOf`/`oneOf` variants so the most informative shape wins.
    /// Composite-only types (a bare `anyOf`/`oneOf` with no `type`) are
    /// treated like objects so the renderer follows them through; bare
    /// `null` is the worst pick.
    fn pick_primary_variant<'a>(variants: &'a [Value], ctx: &'a Ctx) -> &'a Value {
        fn rank(schema: &Value) -> u8 {
            if schema.get("anyOf").is_some() || schema.get("oneOf").is_some() {
                return 6;
            }
            match primary_type(schema).as_deref() {
                Some("object") => 5,
                Some("array") => 4,
                Some("integer" | "number") => 3,
                Some("boolean") => 2,
                Some("string") => 1,
                _ => 0,
            }
        }
        variants
            .iter()
            .max_by_key(|v| rank(ctx.resolve(v)))
            .unwrap_or(&variants[0])
    }

    fn emit_inline_or_block(out: &mut String, value: &Value, indent: usize) {
        match value {
            Value::Object(_) | Value::Array(_) if !is_empty_collection(value) => {
                let yaml = serde_yaml::to_string(value).unwrap_or_default();
                for line in yaml.lines() {
                    out.push('\n');
                    push_indent(out, indent + 1);
                    out.push_str(line);
                }
            }
            _ => {
                out.push(' ');
                let yaml = serde_yaml::to_string(value).unwrap_or_default();
                out.push_str(yaml.trim_end());
            }
        }
    }

    fn is_empty_collection(v: &Value) -> bool {
        matches!(v, Value::Object(o) if o.is_empty())
            || matches!(v, Value::Array(a) if a.is_empty())
    }

    fn first_example(schema: &Value) -> Option<&Value> {
        schema
            .get("examples")
            .and_then(Value::as_array)
            .and_then(|a| a.first())
    }

    /// Pick the first non-null type when `type` is an array (e.g. `["string", "null"]`).
    fn primary_type(schema: &Value) -> Option<String> {
        match schema.get("type")? {
            Value::String(s) => Some(s.clone()),
            Value::Array(a) => a
                .iter()
                .filter_map(Value::as_str)
                .find(|t| *t != "null")
                .or_else(|| a.iter().find_map(Value::as_str))
                .map(str::to_owned),
            _ => None,
        }
    }

    fn push_indent(out: &mut String, indent: usize) {
        for _ in 0..indent {
            let _ = write!(out, "  ");
        }
    }
}