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::preflight_run,
42        runs::start_run,
43        runs::cancel_run,
44        runs::update_notes,
45        runs::delete_notes,
46        runs::write_run,
47        runs::revert_run,
48        stream::stream_run,
49        opc::servers,
50        opc::capabilities,
51        opc::browse,
52        opc::close_browse_session,
53        opc::search,
54        opc::search_index_status,
55        opc::search_index,
56        opc::refresh_search_index,
57        opc::set_search_index_auto_refresh,
58        opc::control_search_index,
59        opc::delete_search_index,
60        opc::read,
61    ),
62    components(schemas(
63        health::Health,
64        templates::TemplateResponse,
65        history::RunSummaryResponse,
66        history::RunListResponse,
67        history::InitialReadingsResponse,
68        history::SampleResponse,
69        history::ResultResponse,
70        history::WriteResponse,
71        history::MvActuationResponse,
72        history::PidConstantTagsResponse,
73        history::PidParameterLabelsResponse,
74        history::RunDetailResponse,
75        bhtune_db::models::EffectiveTuning,
76        history::RunExportFormat,
77        runs::PreflightCheckResponse,
78        runs::PreflightResponse,
79        runs::PreflightStatus,
80        runs::PreflightTagReadResponse,
81        runs::StartRunRequest,
82        draft::NewRunDraft,
83        config::ConfigResponse,
84        config::ConfigValues,
85        config::ConfigTuningValues,
86        config::ConfigTuningSources,
87        config::ConfigTuningTomlValues,
88        config::ConfigTomlValues,
89        config::ConfigSources,
90        config::UpdateConfigRequest,
91        config::UpdateTuningRequest,
92        runs::UpdateNotesRequest,
93        runs::WriteRunRequest,
94        stream::RunStreamDone,
95        opc::OpcServersResponse,
96        opc::OpcCapabilitiesResponse,
97        opc::OpcBrowseNodeKind,
98        opc::OpcBrowseNodeResponse,
99        opc::OpcBrowseResponse,
100        opc::OpcCloseBrowseSessionResponse,
101        opc::OpcIndexedSearchProgressResponse,
102        opc::OpcIndexSchedulerResponse,
103        opc::OpcSearchIndexStatusResponse,
104        opc::OpcIndexedSearchMatchResponse,
105        opc::OpcSearchIndexResponse,
106        opc::OpcReadResponse,
107        opc::GatewayCompatibilityResponse,
108        opc::GatewayFeatureCompatibilityResponse,
109        opc::GatewayProtocolRangeResponse,
110        capabilities::CapabilitiesResponse,
111        capabilities::CapabilityActions,
112        capabilities::FloatBounds,
113        capabilities::IntegerBounds,
114        capabilities::ProcessControllerCompatibility,
115        capabilities::DemoSimulatorDefaults,
116        capabilities::DemoSimulatorLimits,
117        capabilities::DemoSimulatorCapabilities,
118        capabilities::DemoRestrictions,
119        capabilities::DemoQuotas,
120        capabilities::CookieCapabilities,
121        capabilities::SecurityCapabilities,
122        ErrorBody,
123    )),
124    tags(
125        (name = "health", description = "Liveness probe"),
126        (name = "templates", description = "DCS/PLC template catalog (built-in, community-catalog, and user-created)"),
127        (name = "runs", description = "Start, cancel, and browse the history of tune runs"),
128        (name = "config", description = "Global TOML-backed configuration"),
129        (name = "opc", description = "OPC DA server/tag diagnostics and gateway-owned namespace search"),
130    ),
131)]
132pub struct ApiDoc;
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn generated_spec_is_openapi_3_1() {
140        let spec = ApiDoc::openapi();
141        let json = spec.to_json().expect("spec must serialize to JSON");
142        assert!(json.contains("\"openapi\":\"3.1.0\""));
143    }
144
145    #[test]
146    fn generated_spec_serializes_to_json() {
147        let spec = ApiDoc::openapi();
148        let json = spec.to_json().expect("spec must serialize to JSON");
149        assert!(json.contains("\"title\":\"BHTune API\""));
150    }
151
152    #[test]
153    fn generated_spec_includes_indexed_search_routes_and_schemas() {
154        let spec = ApiDoc::openapi();
155        let document: serde_json::Value =
156            serde_json::from_str(&spec.to_json().expect("spec must serialize to JSON"))
157                .expect("generated spec must be valid JSON");
158
159        for (path, method) in [
160            ("/api/opc/search-index/status", "get"),
161            ("/api/opc/search-index/search", "get"),
162            ("/api/opc/search-index/refresh", "post"),
163            ("/api/opc/search-index/auto-refresh", "post"),
164            ("/api/opc/search-index/control", "post"),
165            ("/api/opc/search-index", "delete"),
166        ] {
167            assert!(
168                document["paths"][path][method].is_object(),
169                "missing {method} {path}"
170            );
171        }
172
173        for schema in [
174            "OpcIndexedSearchProgressResponse",
175            "OpcSearchIndexStatusResponse",
176            "OpcIndexSchedulerResponse",
177            "OpcIndexedSearchMatchResponse",
178            "OpcSearchIndexResponse",
179        ] {
180            assert!(
181                document["components"]["schemas"][schema].is_object(),
182                "missing schema {schema}"
183            );
184        }
185    }
186
187    #[test]
188    fn generated_spec_documents_full_mode_preflight_request_and_report() {
189        let spec = ApiDoc::openapi();
190        let document: serde_json::Value =
191            serde_json::from_str(&spec.to_json().expect("generated spec must be valid JSON"))
192                .expect("generated spec must be valid JSON");
193        let operation = &document["paths"]["/api/runs/preflight"]["post"];
194
195        assert!(operation.is_object(), "missing POST /api/runs/preflight");
196        assert_eq!(
197            operation["requestBody"]["content"]["application/json"]["schema"]["$ref"],
198            "#/components/schemas/StartRunRequest"
199        );
200        assert_eq!(
201            operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"],
202            "#/components/schemas/PreflightResponse"
203        );
204        for schema in [
205            "PreflightResponse",
206            "PreflightCheckResponse",
207            "PreflightTagReadResponse",
208            "PreflightStatus",
209        ] {
210            assert!(
211                document["components"]["schemas"][schema].is_object(),
212                "missing schema {schema}"
213            );
214        }
215    }
216
217    #[test]
218    fn indexed_and_progressive_search_limits_require_positive_values() {
219        let spec = ApiDoc::openapi();
220        let value = serde_json::to_value(spec).expect("spec must serialize to a JSON value");
221
222        for path in ["/api/opc/search", "/api/opc/search-index/search"] {
223            assert_eq!(
224                value["paths"][path]["get"]["parameters"]
225                    .as_array()
226                    .and_then(|parameters| {
227                        parameters
228                            .iter()
229                            .find(|parameter| parameter["name"] == "max_results")
230                    })
231                    .and_then(|parameter| parameter.pointer("/schema/minimum")),
232                Some(&serde_json::json!(1)),
233                "{path} must document a positive max_results minimum"
234            );
235        }
236    }
237}