Skip to main content

bhtune_server/
openapi.rs

1//! [`ApiDoc`]: the single [`utoipa::OpenApi`] aggregator listing every route and schema this
2//! crate serves. Kept as its own module (rather than folded into `lib.rs`) because this list
3//! is the one place that must be updated whenever a route or DTO is added -- a dedicated file
4//! makes that omission easy to spot in a diff.
5//!
6//! There is deliberately no macro or build script scanning `routes/**` for `#[utoipa::path]`
7//! annotations automatically: `utoipa`'s own design is to declare the aggregate explicitly, and
8//! a missing entry here fails loudly (the route exists and works, but is simply absent from
9//! `/api/openapi.json` and the Scalar docs at `/api/docs`) rather than silently -- easy to
10//! catch by comparing `routes::*::router()` against this list in review.
11
12use utoipa::OpenApi;
13
14use crate::error::ErrorBody;
15use crate::routes::{capabilities, config, draft, health, history, opc, runs, stream, templates};
16
17#[derive(OpenApi)]
18#[openapi(
19    info(
20        title = "BHTune API",
21        description = "HTTP API for BHTune: DCS/PLC PID-loop templates, and the tune-run history recorded by the CLI and this server.",
22        license(name = "AGPL-3.0-or-later"),
23    ),
24    paths(
25        health::health,
26        capabilities::capabilities,
27        templates::list_templates,
28        templates::get_template,
29        templates::create_template,
30        templates::update_template,
31        templates::delete_template,
32        history::list_runs,
33        history::last_request,
34        draft::get_draft,
35        draft::put_draft,
36        config::get_config,
37        config::put_config,
38        history::show_run,
39        history::export_run,
40        history::delete_run,
41        runs::start_run,
42        runs::cancel_run,
43        runs::update_notes,
44        runs::delete_notes,
45        runs::write_run,
46        runs::revert_run,
47        stream::stream_run,
48        opc::servers,
49        opc::capabilities,
50        opc::browse,
51        opc::close_browse_session,
52        opc::search,
53        opc::search_index_status,
54        opc::search_index,
55        opc::refresh_search_index,
56        opc::set_search_index_auto_refresh,
57        opc::control_search_index,
58        opc::delete_search_index,
59        opc::read,
60    ),
61    components(schemas(
62        health::Health,
63        templates::TemplateResponse,
64        history::RunSummaryResponse,
65        history::RunListResponse,
66        history::InitialReadingsResponse,
67        history::SampleResponse,
68        history::ResultResponse,
69        history::WriteResponse,
70        history::MvActuationResponse,
71        history::PidConstantTagsResponse,
72        history::PidParameterLabelsResponse,
73        history::RunDetailResponse,
74        bhtune_db::models::EffectiveTuning,
75        history::RunExportFormat,
76        runs::StartRunRequest,
77        draft::NewRunDraft,
78        config::ConfigResponse,
79        config::ConfigValues,
80        config::ConfigTuningValues,
81        config::ConfigTuningSources,
82        config::ConfigTuningTomlValues,
83        config::ConfigTomlValues,
84        config::ConfigSources,
85        config::UpdateConfigRequest,
86        config::UpdateTuningRequest,
87        runs::UpdateNotesRequest,
88        runs::WriteRunRequest,
89        stream::RunStreamDone,
90        opc::OpcServersResponse,
91        opc::OpcCapabilitiesResponse,
92        opc::OpcBrowseNodeKind,
93        opc::OpcBrowseNodeResponse,
94        opc::OpcBrowseResponse,
95        opc::OpcCloseBrowseSessionResponse,
96        opc::OpcIndexedSearchProgressResponse,
97        opc::OpcIndexSchedulerResponse,
98        opc::OpcSearchIndexStatusResponse,
99        opc::OpcIndexedSearchMatchResponse,
100        opc::OpcSearchIndexResponse,
101        opc::OpcReadResponse,
102        capabilities::CapabilitiesResponse,
103        capabilities::CapabilityActions,
104        capabilities::FloatBounds,
105        capabilities::IntegerBounds,
106        capabilities::ProcessControllerCompatibility,
107        capabilities::DemoSimulatorDefaults,
108        capabilities::DemoSimulatorLimits,
109        capabilities::DemoSimulatorCapabilities,
110        capabilities::DemoRestrictions,
111        capabilities::DemoQuotas,
112        capabilities::CookieCapabilities,
113        capabilities::SecurityCapabilities,
114        ErrorBody,
115    )),
116    tags(
117        (name = "health", description = "Liveness probe"),
118        (name = "templates", description = "DCS/PLC template catalog (built-in, community-catalog, and user-created)"),
119        (name = "runs", description = "Start, cancel, and browse the history of tune runs"),
120        (name = "config", description = "Global TOML-backed configuration"),
121        (name = "opc", description = "OPC DA server/tag diagnostics and gateway-owned namespace search"),
122    ),
123)]
124pub struct ApiDoc;
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn generated_spec_is_openapi_3_1() {
132        let spec = ApiDoc::openapi();
133        let json = spec.to_json().expect("spec must serialize to JSON");
134        assert!(json.contains("\"openapi\":\"3.1.0\""));
135    }
136
137    #[test]
138    fn generated_spec_serializes_to_json() {
139        let spec = ApiDoc::openapi();
140        let json = spec.to_json().expect("spec must serialize to JSON");
141        assert!(json.contains("\"title\":\"BHTune API\""));
142    }
143
144    #[test]
145    fn generated_spec_includes_indexed_search_routes_and_schemas() {
146        let spec = ApiDoc::openapi();
147        let document: serde_json::Value =
148            serde_json::from_str(&spec.to_json().expect("spec must serialize to JSON"))
149                .expect("generated spec must be valid JSON");
150
151        for (path, method) in [
152            ("/api/opc/search-index/status", "get"),
153            ("/api/opc/search-index/search", "get"),
154            ("/api/opc/search-index/refresh", "post"),
155            ("/api/opc/search-index/auto-refresh", "post"),
156            ("/api/opc/search-index/control", "post"),
157            ("/api/opc/search-index", "delete"),
158        ] {
159            assert!(
160                document["paths"][path][method].is_object(),
161                "missing {method} {path}"
162            );
163        }
164
165        for schema in [
166            "OpcIndexedSearchProgressResponse",
167            "OpcSearchIndexStatusResponse",
168            "OpcIndexSchedulerResponse",
169            "OpcIndexedSearchMatchResponse",
170            "OpcSearchIndexResponse",
171        ] {
172            assert!(
173                document["components"]["schemas"][schema].is_object(),
174                "missing schema {schema}"
175            );
176        }
177    }
178
179    #[test]
180    fn indexed_and_progressive_search_limits_require_positive_values() {
181        let spec = ApiDoc::openapi();
182        let value = serde_json::to_value(spec).expect("spec must serialize to a JSON value");
183
184        for path in ["/api/opc/search", "/api/opc/search-index/search"] {
185            assert_eq!(
186                value["paths"][path]["get"]["parameters"]
187                    .as_array()
188                    .and_then(|parameters| {
189                        parameters
190                            .iter()
191                            .find(|parameter| parameter["name"] == "max_results")
192                    })
193                    .and_then(|parameter| parameter.pointer("/schema/minimum")),
194                Some(&serde_json::json!(1)),
195                "{path} must document a positive max_results minimum"
196            );
197        }
198    }
199}