Skip to main content

poolster_core/
semantics.rs

1//! OpenAPI-derived SDK behaviour shared by language targets.
2//!
3//! Renderers should not each rediscover whether an operation streams, can be
4//! retried, needs a credential, or has typed error responses. This module is
5//! deliberately free of language and HTTP-client details: it turns the
6//! normalized API AST into a small, stable behaviour plan which a selected
7//! client generator can render idiomatically.
8
9use crate::{
10    Api, HttpMethod, Operation, OperationMediaType, SecuritySchemeCatalog, SecuritySchemeKind,
11};
12
13/// The complete target-neutral behaviour plan for one API.
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct SdkSemantics {
16    pub operations: Vec<OperationSemantics>,
17}
18
19/// Behaviour that applies to one generated operation.
20#[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/// One OpenAPI security alternative. Every item inside an alternative is
32/// required; alternatives themselves are OR-ed.
33#[derive(Clone, Debug, Default, PartialEq, Eq)]
34pub struct AuthAlternative {
35    pub schemes: Vec<AuthScheme>,
36}
37
38/// A resolved credential requirement. Unknown schemes are deliberately kept
39/// as `Other` so targets can report a useful configuration error rather than
40/// quietly omitting security.
41#[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/// A non-success response declared by an operation.
51#[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/// Retry safety derived from HTTP semantics and explicit idempotency headers.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum RetryClass {
61    /// GET, PUT, and DELETE may use the selected runtime's normal retry
62    /// policy. A client still decides which failures/statuses are retryable.
63    Idempotent,
64    /// POST/PATCH require an explicitly declared idempotency key for retry.
65    IdempotencyKey,
66    /// Do not retry automatically.
67    Unsafe,
68}
69
70/// A response whose media type requires a streaming API instead of ordinary
71/// JSON deserialization.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum StreamingKind {
74    ServerSentEvents,
75    Binary,
76}
77
78/// Request encodings that require specialised client-runtime handling.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum RequestBodyKind {
81    Json,
82    Multipart,
83    FormUrlEncoded,
84    Binary,
85    Other,
86}
87
88/// An opt-in pagination declaration. Pagination is intentionally not guessed
89/// from operation names: a false pager is worse than no pager. Adapters retain
90/// vendor extensions, so Poolster accepts its own portable extension and the
91/// established Speakeasy declaration while keeping their payload opaque until
92/// a concrete paginator renderer needs it.
93#[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
104/// Analyses every operation once. A caller that has no component security
105/// artifact can pass `None`; operation security remains visible as `Other`.
106pub 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}