1use crate::{
10 Api, HttpMethod, Operation, OperationMediaType, SecuritySchemeCatalog, SecuritySchemeKind,
11};
12
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct SdkSemantics {
16 pub operations: Vec<OperationSemantics>,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct OperationSemantics {
22 pub operation_id: String,
23 pub auth: Vec<AuthAlternative>,
24 pub errors: Vec<DeclaredError>,
25 pub retry: RetryClass,
26 pub streaming: Option<StreamingKind>,
27 pub request_body: Option<RequestBodyKind>,
28 pub pagination: Option<PaginationHint>,
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
34pub struct AuthAlternative {
35 pub schemes: Vec<AuthScheme>,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum AuthScheme {
43 ApiKey { name: String, location: String },
44 Http { scheme: String },
45 OAuth2 { scopes: Vec<String> },
46 OpenIdConnect { discovery_url: Option<String> },
47 Other { name: String },
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct DeclaredError {
53 pub status: String,
54 pub body_type: Option<String>,
55 pub content_type: Option<String>,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum RetryClass {
61 Idempotent,
64 IdempotencyKey,
66 Unsafe,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum StreamingKind {
74 ServerSentEvents,
75 Binary,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum RequestBodyKind {
81 Json,
82 Multipart,
83 FormUrlEncoded,
84 Binary,
85 Other,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct PaginationHint {
95 pub source: PaginationSource,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum PaginationSource {
100 Poolster,
101 Speakeasy,
102}
103
104pub fn analyze_sdk_semantics(
107 api: &Api,
108 security_schemes: Option<&SecuritySchemeCatalog>,
109) -> SdkSemantics {
110 SdkSemantics {
111 operations: api
112 .operations
113 .iter()
114 .map(|operation| analyze_operation(operation, security_schemes))
115 .collect(),
116 }
117}
118
119pub fn analyze_operation(
120 operation: &Operation,
121 security_schemes: Option<&SecuritySchemeCatalog>,
122) -> OperationSemantics {
123 OperationSemantics {
124 operation_id: operation.id.clone(),
125 auth: operation
126 .security
127 .iter()
128 .map(|requirement| AuthAlternative {
129 schemes: requirement
130 .schemes
131 .iter()
132 .map(|(name, scopes)| resolve_auth(name, scopes, security_schemes))
133 .collect(),
134 })
135 .collect(),
136 errors: operation
137 .responses
138 .iter()
139 .filter(|response| is_error_status(&response.status))
140 .map(|response| DeclaredError {
141 status: response.status.clone(),
142 body_type: response
143 .media_types
144 .first()
145 .and_then(|media| media.schema.as_ref())
146 .and_then(|schema| schema.kind.reference_name())
147 .map(str::to_owned),
148 content_type: response
149 .media_types
150 .first()
151 .map(|media| media.content_type.clone()),
152 })
153 .collect(),
154 retry: retry_class(operation),
155 streaming: streaming_kind(operation),
156 request_body: operation
157 .request_body
158 .as_ref()
159 .and_then(|body| body.media_types.first())
160 .map(request_body_kind),
161 pagination: pagination_hint(operation),
162 }
163}
164
165fn resolve_auth(
166 name: &str,
167 scopes: &[String],
168 security_schemes: Option<&SecuritySchemeCatalog>,
169) -> AuthScheme {
170 let scheme = security_schemes
171 .and_then(|catalog| catalog.schemes.iter().find(|scheme| scheme.name == name));
172 match scheme.map(|scheme| &scheme.kind) {
173 Some(SecuritySchemeKind::ApiKey { name, location }) => AuthScheme::ApiKey {
174 name: name.clone().unwrap_or_else(|| "Authorization".into()),
175 location: location.clone().unwrap_or_else(|| "header".into()),
176 },
177 Some(SecuritySchemeKind::Http { scheme, .. }) => AuthScheme::Http {
178 scheme: scheme.clone().unwrap_or_else(|| "bearer".into()),
179 },
180 Some(SecuritySchemeKind::OAuth2 { .. }) => AuthScheme::OAuth2 {
181 scopes: scopes.to_vec(),
182 },
183 Some(SecuritySchemeKind::OpenIdConnect { discovery_url }) => AuthScheme::OpenIdConnect {
184 discovery_url: discovery_url.clone(),
185 },
186 Some(SecuritySchemeKind::Other { .. }) | None => AuthScheme::Other { name: name.into() },
187 }
188}
189
190fn is_error_status(status: &str) -> bool {
191 status == "default"
192 || status
193 .parse::<u16>()
194 .is_ok_and(|status| (400..600).contains(&status))
195}
196
197fn retry_class(operation: &Operation) -> RetryClass {
198 let resolved_header = operation
199 .annotations
200 .get(crate::idempotency::RESOLVED_ANNOTATION)
201 .and_then(|value| value.get("header"))
202 .and_then(serde_json::Value::as_str);
203 if operation.parameters.iter().any(|parameter| {
204 parameter.location == "header"
205 && (parameter.name.eq_ignore_ascii_case("idempotency-key")
206 || resolved_header
207 .is_some_and(|header| parameter.name.eq_ignore_ascii_case(header)))
208 }) {
209 return RetryClass::IdempotencyKey;
210 }
211 match operation.method {
212 HttpMethod::Get
213 | HttpMethod::Put
214 | HttpMethod::Delete
215 | HttpMethod::Head
216 | HttpMethod::Options
217 | HttpMethod::Trace
218 | HttpMethod::Query => RetryClass::Idempotent,
219 HttpMethod::Post | HttpMethod::Patch | HttpMethod::Custom(_) => RetryClass::Unsafe,
220 }
221}
222
223fn streaming_kind(operation: &Operation) -> Option<StreamingKind> {
224 operation
225 .responses
226 .iter()
227 .flat_map(|response| &response.media_types)
228 .find_map(|media| {
229 let content_type = media
230 .content_type
231 .split(';')
232 .next()
233 .unwrap_or_default()
234 .trim()
235 .to_ascii_lowercase();
236 match content_type.as_str() {
237 "text/event-stream" => Some(StreamingKind::ServerSentEvents),
238 "application/octet-stream" | "application/pdf" | "image/png" | "image/jpeg" => {
239 Some(StreamingKind::Binary)
240 }
241 _ if content_type.starts_with("multipart/") => Some(StreamingKind::Binary),
242 _ => None,
243 }
244 })
245}
246
247fn request_body_kind(media: &OperationMediaType) -> RequestBodyKind {
248 let content_type = media
249 .content_type
250 .split(';')
251 .next()
252 .unwrap_or_default()
253 .trim()
254 .to_ascii_lowercase();
255 match content_type.as_str() {
256 "application/json" | "application/problem+json" => RequestBodyKind::Json,
257 _ if content_type.starts_with("multipart/") => RequestBodyKind::Multipart,
258 "application/x-www-form-urlencoded" => RequestBodyKind::FormUrlEncoded,
259 "application/octet-stream" => RequestBodyKind::Binary,
260 _ => RequestBodyKind::Other,
261 }
262}
263
264fn pagination_hint(operation: &Operation) -> Option<PaginationHint> {
265 if operation.annotations.contains_key("x-poolster-pagination") {
266 Some(PaginationHint {
267 source: PaginationSource::Poolster,
268 })
269 } else if operation.annotations.contains_key("x-speakeasy-pagination") {
270 Some(PaginationHint {
271 source: PaginationSource::Speakeasy,
272 })
273 } else {
274 None
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use std::collections::BTreeMap;
281
282 use serde_json::json;
283
284 use crate::{
285 OperationMediaType, OperationParameter, OperationResponse, SchemaKind, SchemaValue,
286 SecurityRequirement, SecurityScheme,
287 };
288
289 use super::*;
290
291 #[test]
292 fn query_and_standard_safe_methods_are_replayable() {
293 for method in [
294 HttpMethod::Query,
295 HttpMethod::Head,
296 HttpMethod::Options,
297 HttpMethod::Trace,
298 ] {
299 assert_eq!(
300 retry_class(&Operation {
301 method,
302 ..Operation::default()
303 }),
304 RetryClass::Idempotent
305 );
306 }
307 }
308
309 #[test]
310 fn patch_requires_explicit_key_and_custom_resolved_header_is_recognized() {
311 let mut operation = Operation {
312 id: "patchItem".into(),
313 method: HttpMethod::Patch,
314 ..Default::default()
315 };
316 assert_eq!(retry_class(&operation), RetryClass::Unsafe);
317 let mut api = Api::default();
318 operation.annotations.insert(
319 "x-poolster-idempotency".into(),
320 serde_json::json!({"header":"X-Request-Key"}),
321 );
322 api.operations.push(operation);
323 let prepared = crate::idempotency::prepare_api(&api, &Default::default()).unwrap();
324 assert_eq!(
325 retry_class(&prepared.operations[0]),
326 RetryClass::IdempotencyKey
327 );
328 }
329
330 #[test]
331 fn resolves_auth_errors_retry_and_media_from_one_operation() {
332 let operation = Operation {
333 id: "createMessage".into(),
334 method: HttpMethod::Post,
335 path: "/messages".into(),
336 parameters: vec![OperationParameter {
337 name: "Idempotency-Key".into(),
338 location: "header".into(),
339 required: false,
340 schema: None,
341 description: None,
342 annotations: BTreeMap::new(),
343 }],
344 request_body: Some(crate::OperationRequestBody {
345 required: true,
346 description: None,
347 media_types: vec![OperationMediaType {
348 content_type: "multipart/form-data".into(),
349 schema: None,
350 }],
351 }),
352 responses: vec![
353 OperationResponse {
354 status: "201".into(),
355 description: None,
356 media_types: vec![],
357 },
358 OperationResponse {
359 status: "429".into(),
360 description: None,
361 media_types: vec![OperationMediaType {
362 content_type: "application/json".into(),
363 schema: Some(SchemaValue::reference(
364 "#/components/schemas/RateLimitError",
365 )),
366 }],
367 },
368 OperationResponse {
369 status: "default".into(),
370 description: None,
371 media_types: vec![],
372 },
373 ],
374 security: vec![SecurityRequirement {
375 schemes: BTreeMap::from([("apiKey".into(), vec![])]),
376 }],
377 annotations: BTreeMap::from([("x-poolster-pagination".into(), json!({}))]),
378 };
379 let catalog = SecuritySchemeCatalog {
380 schemes: vec![SecurityScheme {
381 name: "apiKey".into(),
382 description: None,
383 kind: SecuritySchemeKind::ApiKey {
384 name: Some("X-API-Key".into()),
385 location: Some("header".into()),
386 },
387 }],
388 };
389
390 let semantics = analyze_operation(&operation, Some(&catalog));
391 assert_eq!(semantics.retry, RetryClass::IdempotencyKey);
392 assert_eq!(semantics.request_body, Some(RequestBodyKind::Multipart));
393 assert_eq!(semantics.errors.len(), 2);
394 assert_eq!(
395 semantics.errors[0].body_type.as_deref(),
396 Some("RateLimitError")
397 );
398 assert_eq!(
399 semantics.auth,
400 vec![AuthAlternative {
401 schemes: vec![AuthScheme::ApiKey {
402 name: "X-API-Key".into(),
403 location: "header".into(),
404 }],
405 }]
406 );
407 assert_eq!(
408 semantics.pagination,
409 Some(PaginationHint {
410 source: PaginationSource::Poolster,
411 })
412 );
413 }
414
415 #[test]
416 fn detects_server_sent_events_and_does_not_guess_pagination() {
417 let operation = Operation {
418 id: "watchEvents".into(),
419 responses: vec![OperationResponse {
420 status: "200".into(),
421 description: None,
422 media_types: vec![OperationMediaType {
423 content_type: "text/event-stream".into(),
424 schema: Some(SchemaValue::new(SchemaKind::String)),
425 }],
426 }],
427 ..Operation::default()
428 };
429 let semantics = analyze_operation(&operation, None);
430 assert_eq!(semantics.streaming, Some(StreamingKind::ServerSentEvents));
431 assert_eq!(semantics.pagination, None);
432 assert_eq!(semantics.retry, RetryClass::Idempotent);
433 }
434}
435
436#[cfg(test)]
437mod custom_method_tests {
438 use super::*;
439 #[test]
440 fn custom_method_is_unsafe_without_explicit_idempotency() {
441 let operation = Operation {
442 method: HttpMethod::Custom("COPY".into()),
443 ..Default::default()
444 };
445 assert_eq!(retry_class(&operation), RetryClass::Unsafe);
446 }
447}