1use 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 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 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
240pub 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 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
265pub 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}