1use 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}