Skip to main content

poolster_core/
vendor.rs

1//! Explicit, reviewable compatibility with vendor OpenAPI annotations.
2//! Wire paths, property names and authentication contracts are never renamed.
3use crate::Api;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use std::collections::BTreeMap;
7
8#[derive(Clone, Debug, Default, Serialize, Deserialize)]
9pub struct MigrationReport {
10    pub converted: Vec<String>,
11    pub manual: Vec<String>,
12}
13
14fn normalize_operation(
15    id: &mut String,
16    annotations: &mut BTreeMap<String, Value>,
17    root: &Value,
18    report: &mut MigrationReport,
19) -> bool {
20    let original = id.clone();
21    for key in [
22        "x-fern-sdk-group-name",
23        "x-fern-sdk-method-name",
24        "x-speakeasy-group",
25        "x-speakeasy-name-override",
26    ] {
27        if annotations.get(key).is_some_and(|value| !value.is_string()) {
28            report.manual.push(format!(
29                "{original}: {key} requires an explicit string binding"
30            ));
31        }
32    }
33    for key in ["x-fern-ignore", "x-speakeasy-ignore", "x-fern-idempotent"] {
34        if annotations
35            .get(key)
36            .is_some_and(|value| !value.is_boolean())
37        {
38            report
39                .manual
40                .push(format!("{original}: {key} requires a boolean"));
41        }
42    }
43    if let Some(object) = annotations
44        .get("x-stainless-method")
45        .and_then(Value::as_object)
46    {
47        for key in object.keys().filter(|key| key.as_str() != "path") {
48            report.manual.push(format!(
49                "{original}: x-stainless-method.{key} is preserved but not translated"
50            ));
51        }
52    }
53    let mut methods = Vec::new();
54    if let Some(value) = annotations.get("x-stainless-method") {
55        if let Some(path) = value
56            .as_str()
57            .or_else(|| value.get("path").and_then(Value::as_str))
58        {
59            methods.push(path.trim_start_matches("$client.").replace('.', "_"));
60        } else {
61            report.manual.push(format!(
62                "{original}: x-stainless-method has an unsupported shape"
63            ));
64        }
65    }
66    let group = annotations
67        .get("x-fern-sdk-group-name")
68        .and_then(Value::as_str)
69        .or_else(|| annotations.get("x-speakeasy-group").and_then(Value::as_str));
70    let method = annotations
71        .get("x-fern-sdk-method-name")
72        .and_then(Value::as_str)
73        .or_else(|| {
74            annotations
75                .get("x-speakeasy-name-override")
76                .and_then(Value::as_str)
77        });
78    if let Some(method) = method {
79        methods.push(group.map_or_else(|| method.to_owned(), |group| format!("{group}_{method}")));
80    }
81    methods.sort();
82    methods.dedup();
83    if methods.len() == 1 && !methods[0].is_empty() {
84        *id = methods[0].clone();
85        report
86            .converted
87            .push(format!("{original}: SDK operation name -> {id}"));
88    } else if methods.len() > 1 {
89        report
90            .manual
91            .push(format!("{original}: conflicting vendor method names"));
92    }
93    if let Some(group) = group {
94        annotations.insert("tags".into(), json!([group]));
95    }
96    if !annotations.contains_key("x-poolster-pagination") {
97        if let Some(value) = annotations.get("x-speakeasy-pagination").cloned() {
98            // Validation remains owned by the portable pagination contract.
99            annotations.insert("x-poolster-pagination".into(), value);
100            report
101                .converted
102                .push(format!("{original}: Speakeasy pagination"));
103        } else if let Some(value) = annotations.get("x-fern-pagination") {
104            if let Some(rule) = fern_pagination(value) {
105                annotations.insert("x-poolster-pagination".into(), rule);
106                report
107                    .converted
108                    .push(format!("{original}: Fern cursor pagination"));
109            } else {
110                report.manual.push(format!("{original}: pagination requires explicit x-poolster-pagination (unsupported Fern binding)"));
111            }
112        }
113    }
114    if annotations.get("x-fern-idempotent") == Some(&Value::Bool(true))
115        && !annotations.contains_key("x-poolster-idempotency")
116    {
117        let headers = root
118            .get("x-fern-idempotency-headers")
119            .and_then(Value::as_array);
120        if let Some(header) = headers
121            .filter(|h| h.len() == 1)
122            .and_then(|h| h[0].get("header"))
123            .and_then(Value::as_str)
124        {
125            annotations.insert(
126                "x-poolster-idempotency".into(),
127                json!({"header":header,"auto_generate":false}),
128            );
129            report
130                .converted
131                .push(format!("{original}: idempotency header"));
132        } else {
133            report.manual.push(format!(
134                "{original}: idempotency requires one explicit header"
135            ));
136        }
137    }
138    for key in annotations.keys().filter(|key| vendor_key(key)) {
139        if !matches!(
140            key.as_str(),
141            "x-stainless-method"
142                | "x-fern-sdk-group-name"
143                | "x-fern-sdk-method-name"
144                | "x-speakeasy-group"
145                | "x-speakeasy-name-override"
146                | "x-speakeasy-pagination"
147                | "x-fern-pagination"
148                | "x-fern-idempotent"
149                | "x-fern-ignore"
150                | "x-speakeasy-ignore"
151        ) {
152            report
153                .manual
154                .push(format!("{original}: {key} is preserved but not translated"));
155        }
156    }
157    let ignored = ["x-fern-ignore", "x-speakeasy-ignore"]
158        .iter()
159        .any(|key| annotations.get(*key) == Some(&Value::Bool(true)));
160    if ignored {
161        report
162            .converted
163            .push(format!("{original}: excluded operation"));
164    }
165    ignored
166}
167fn vendor_key(key: &str) -> bool {
168    ["x-fern-", "x-stainless-", "x-speakeasy-"]
169        .iter()
170        .any(|prefix| key.starts_with(prefix))
171}
172fn fern_pagination(value: &Value) -> Option<Value> {
173    // Only bindings whose semantics are exactly representable are automatic.
174    if value.get("offset").is_some()
175        || value.get("next_path").is_some()
176        || value.get("has-next-page").is_some()
177    {
178        return None;
179    }
180    let cursor = value.get("cursor")?.as_str()?.strip_prefix("$request.")?;
181    if cursor.is_empty() || cursor.contains('.') {
182        return None;
183    }
184    let results = value.get("results")?.as_str()?.strip_prefix("$response.")?;
185    let next = value
186        .get("next_cursor")?
187        .as_str()?
188        .strip_prefix("$response.")?;
189    Some(
190        json!({"type":"cursor","inputs":[{"name":cursor,"type":"cursor","in":"parameters"}],"outputs":{"results":format!("$.{results}"),"nextCursor":format!("$.{next}")}}),
191    )
192}
193
194fn report_root(root: &Value, report: &mut MigrationReport) {
195    if let Some(root) = root.as_object() {
196        for key in root
197            .keys()
198            .filter(|key| vendor_key(key) && key.as_str() != "x-fern-idempotency-headers")
199        {
200            report
201                .manual
202                .push(format!("document: {key} is preserved but not translated"));
203        }
204    }
205}
206fn report_schema_annotations(value: Option<&Value>, report: &mut MigrationReport) {
207    fn visit(value: &Value, keys: &mut std::collections::BTreeSet<String>) {
208        match value {
209            Value::Object(object) => {
210                for (key, value) in object {
211                    if vendor_key(key) {
212                        keys.insert(key.clone());
213                    } else if !matches!(
214                        key.as_str(),
215                        "example" | "examples" | "default" | "enum" | "const"
216                    ) {
217                        visit(value, keys);
218                    }
219                }
220            }
221            Value::Array(array) => {
222                for value in array {
223                    visit(value, keys);
224                }
225            }
226            _ => {}
227        }
228    }
229    let mut keys = std::collections::BTreeSet::new();
230    if let Some(value) = value {
231        visit(value, &mut keys);
232    }
233    for key in keys {
234        report
235            .manual
236            .push(format!("components: {key} is preserved but not translated"));
237    }
238}
239
240/// Normalize annotations for ordinary generation through the sidecar adapter.
241pub fn normalize_api(api: &mut Api, root: &Value) -> MigrationReport {
242    let mut report = MigrationReport::default();
243    report_root(root, &mut report);
244    api.operations.retain_mut(|operation| {
245        !normalize_operation(
246            &mut operation.id,
247            &mut operation.annotations,
248            root,
249            &mut report,
250        )
251    });
252    // Collisions fail explicitly rather than silently overwriting generated files.
253    let mut ids = std::collections::BTreeSet::new();
254    for operation in &api.operations {
255        if !ids.insert(&operation.id) {
256            report.manual.push(format!(
257                "duplicate SDK operation name {:?}; set unique operationId values",
258                operation.id
259            ));
260        }
261    }
262    report
263}
264
265/// Rewrite operation annotations in a bundled document without changing wire names.
266pub fn normalize_openapi(document: &mut Value) -> MigrationReport {
267    let root =
268        json!({"x-fern-idempotency-headers": document.get("x-fern-idempotency-headers").cloned()});
269    let mut report = MigrationReport::default();
270    report_root(document, &mut report);
271    report_schema_annotations(document.get("components"), &mut report);
272    if let Some(paths) = document.get_mut("paths").and_then(Value::as_object_mut) {
273        for (path, item) in paths {
274            if let Some(item) = item.as_object_mut() {
275                let mut remove = Vec::new();
276                for (method, operation) in item.iter_mut().filter(|(method, _)| {
277                    matches!(
278                        method.as_str(),
279                        "get"
280                            | "put"
281                            | "post"
282                            | "delete"
283                            | "patch"
284                            | "head"
285                            | "options"
286                            | "trace"
287                            | "query"
288                    )
289                }) {
290                    if let Some(operation) = operation.as_object_mut() {
291                        let mut annotations = operation
292                            .iter()
293                            .filter(|(key, _)| key.starts_with("x-") || *key == "tags")
294                            .map(|(k, v)| (k.clone(), v.clone()))
295                            .collect();
296                        let mut id = operation
297                            .get("operationId")
298                            .and_then(Value::as_str)
299                            .map(str::to_owned)
300                            .unwrap_or_else(|| format!("{method}_{path}"));
301                        if normalize_operation(&mut id, &mut annotations, &root, &mut report) {
302                            remove.push(method.clone());
303                        }
304                        if operation.contains_key("operationId") || id != format!("{method}_{path}")
305                        {
306                            operation.insert("operationId".into(), Value::String(id));
307                        }
308                        operation.extend(annotations);
309                    }
310                }
311                for method in remove {
312                    item.remove(&method);
313                }
314            }
315        }
316    }
317    report
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    #[test]
324    fn names_ignore_and_wire_contracts() {
325        let mut doc = json!({"paths":{"/users/{user_id}":{"get":{"operationId":"old","x-fern-sdk-group-name":"users","x-fern-sdk-method-name":"get","parameters":[{"name":"user_id","in":"path"}]},"delete":{"x-speakeasy-ignore":true}}}});
326        let report = normalize_openapi(&mut doc);
327        assert!(report.manual.is_empty());
328        assert_eq!(
329            doc["paths"]["/users/{user_id}"]["get"]["operationId"],
330            "users_get"
331        );
332        assert_eq!(
333            doc["paths"]["/users/{user_id}"]["get"]["parameters"][0]["name"],
334            "user_id"
335        );
336        assert!(doc["paths"]["/users/{user_id}"].get("delete").is_none());
337    }
338    #[test]
339    fn explicit_native_rule_wins_and_unknown_behavior_is_reported() {
340        let mut doc = json!({"paths":{"/a":{"get":{"x-stainless-method":"accounts.list","x-poolster-pagination":{"type":"page"},"x-speakeasy-pagination":{"type":"cursor"},"x-speakeasy-retries":{"secret":"never print me"}}}}});
341        let report = normalize_openapi(&mut doc);
342        assert_eq!(doc["paths"]["/a"]["get"]["operationId"], "accounts_list");
343        assert_eq!(
344            doc["paths"]["/a"]["get"]["x-poolster-pagination"]["type"],
345            "page"
346        );
347        assert!(
348            !serde_json::to_string(&report)
349                .unwrap()
350                .contains("never print me")
351        );
352        assert_eq!(report.manual.len(), 1);
353    }
354    #[test]
355    fn fern_cursor_and_idempotency() {
356        let mut doc = json!({"x-fern-idempotency-headers":[{"header":"Request-Key"}],"paths":{"/a":{"post":{"x-fern-idempotent":true,"x-fern-pagination":{"cursor":"$request.cursor","next_cursor":"$response.next","results":"$response.data"}}}}});
357        assert!(normalize_openapi(&mut doc).manual.is_empty());
358        assert_eq!(
359            doc["paths"]["/a"]["post"]["x-poolster-idempotency"]["header"],
360            "Request-Key"
361        );
362        assert_eq!(
363            doc["paths"]["/a"]["post"]["x-poolster-pagination"]["outputs"]["results"],
364            "$.data"
365        );
366    }
367}