1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value, json};
4
5use crate::OpenApiError;
6
7pub fn crd_yaml_to_openapi(yaml: &str) -> Result<Value, OpenApiError> {
12 let mut schemas = BTreeMap::new();
13
14 for doc in serde_yaml_ng::Deserializer::from_str(yaml) {
15 let value: Value = serde::Deserialize::deserialize(doc)
16 .map_err(|e| crd_err(format!("YAML parse: {e}")))?;
17
18 if !is_crd(&value) {
19 continue;
20 }
21
22 extract_crd(&value, &mut schemas)?;
23 }
24
25 if schemas.is_empty() {
26 return Err(crd_err("no valid CRD documents found".to_string()));
27 }
28
29 let schemas_obj: Map<String, Value> = schemas.into_iter().collect();
30 Ok(json!({
31 "components": {
32 "schemas": schemas_obj
33 }
34 }))
35}
36
37fn is_crd(doc: &Value) -> bool {
39 let api_version = doc.get("apiVersion").and_then(Value::as_str);
40 let kind = doc.get("kind").and_then(Value::as_str);
41 matches!(
42 (api_version, kind),
43 (
44 Some("apiextensions.k8s.io/v1"),
45 Some("CustomResourceDefinition")
46 )
47 )
48}
49
50fn extract_crd(crd: &Value, schemas: &mut BTreeMap<String, Value>) -> Result<(), OpenApiError> {
52 let spec = crd
53 .get("spec")
54 .ok_or_else(|| crd_err("missing spec".to_string()))?;
55
56 let group = spec
57 .get("group")
58 .and_then(Value::as_str)
59 .ok_or_else(|| crd_err("missing spec.group".to_string()))?;
60
61 let kind = spec
62 .pointer("/names/kind")
63 .and_then(Value::as_str)
64 .ok_or_else(|| crd_err("missing spec.names.kind".to_string()))?;
65
66 let versions = spec
67 .get("versions")
68 .and_then(Value::as_array)
69 .ok_or_else(|| crd_err("missing spec.versions".to_string()))?;
70
71 let prefix = reverse_domain(group);
72
73 for ver in versions {
74 let version = ver
75 .get("name")
76 .and_then(Value::as_str)
77 .ok_or_else(|| crd_err("missing version name".to_string()))?;
78
79 let openapi_schema = ver.pointer("/schema/openAPIV3Schema");
80 let Some(raw_schema) = openapi_schema else {
81 continue;
82 };
83
84 let base = format!("{prefix}.{version}");
85
86 let mut extracted = BTreeMap::new();
88 let top_schema = extract_nested_schemas(raw_schema, kind, &base, &mut extracted)?;
89
90 let resource_name = format!("{base}.{kind}");
92 let resource_schema = build_resource_schema(top_schema, group, version, kind);
93 schemas.insert(resource_name, resource_schema);
94
95 schemas.extend(extracted);
97 }
98
99 Ok(())
100}
101
102fn extract_nested_schemas(
106 schema: &Value,
107 context_name: &str,
108 base: &str,
109 extracted: &mut BTreeMap<String, Value>,
110) -> Result<Value, OpenApiError> {
111 let Some(obj) = schema.as_object() else {
112 return Ok(schema.clone());
113 };
114
115 let mut result = obj.clone();
116
117 if let Some(Value::Object(props)) = result.get("properties") {
119 let mut new_props = Map::new();
120 for (prop_name, prop_schema) in props {
121 let new_schema =
122 maybe_extract_property(prop_schema, prop_name, context_name, base, extracted)?;
123 new_props.insert(prop_name.clone(), new_schema);
124 }
125 result.insert("properties".to_string(), Value::Object(new_props));
126 }
127
128 if let Some(items) = obj.get("items") {
130 let new_items = maybe_extract_property(items, context_name, context_name, base, extracted)?;
131 result.insert("items".to_string(), new_items);
132 }
133
134 Ok(Value::Object(result))
135}
136
137fn maybe_extract_property(
139 prop_schema: &Value,
140 prop_name: &str,
141 context_name: &str,
142 base: &str,
143 extracted: &mut BTreeMap<String, Value>,
144) -> Result<Value, OpenApiError> {
145 let Some(obj) = prop_schema.as_object() else {
146 return Ok(prop_schema.clone());
147 };
148
149 if is_extractable_object(obj) {
151 let sub_name = format!("{context_name}{}", to_pascal_case(prop_name));
152 let full_name = format!("{base}.{sub_name}");
153
154 let sub_schema = extract_nested_schemas(prop_schema, &sub_name, base, extracted)?;
155
156 let desc = obj.get("description").cloned();
158
159 extracted.insert(full_name.clone(), sub_schema);
160
161 let ref_value = format!("#/components/schemas/{full_name}");
163 return if let Some(d) = desc {
164 Ok(json!({ "$ref": ref_value, "description": d }))
165 } else {
166 Ok(json!({ "$ref": ref_value }))
167 };
168 }
169
170 if obj.get("type").and_then(Value::as_str) == Some("array")
172 && let Some(items) = obj.get("items")
173 && items.as_object().is_some_and(is_extractable_object)
174 {
175 let sub_name = format!("{context_name}{}", to_pascal_case(prop_name));
176 let full_name = format!("{base}.{sub_name}");
177
178 let sub_schema = extract_nested_schemas(items, &sub_name, base, extracted)?;
179 extracted.insert(full_name.clone(), sub_schema);
180
181 let mut result = obj.clone();
182 result.insert(
183 "items".to_string(),
184 json!({ "$ref": format!("#/components/schemas/{full_name}") }),
185 );
186 return Ok(Value::Object(result));
187 }
188
189 Ok(prop_schema.clone())
190}
191
192fn is_extractable_object(obj: &Map<String, Value>) -> bool {
194 obj.get("type").and_then(Value::as_str) == Some("object")
195 && obj.get("properties").is_some_and(|p| p.is_object())
196}
197
198fn build_resource_schema(mut schema: Value, group: &str, version: &str, kind: &str) -> Value {
200 let obj = schema.as_object_mut().unwrap();
201
202 let props = obj
204 .entry("properties")
205 .or_insert_with(|| json!({}))
206 .as_object_mut()
207 .unwrap();
208
209 props
211 .entry("apiVersion")
212 .or_insert_with(|| json!({"description": "APIVersion defines the versioned schema of this representation of an object.", "type": "string"}));
213 props
214 .entry("kind")
215 .or_insert_with(|| json!({"description": "Kind is a string value representing the REST resource this object represents.", "type": "string"}));
216 props.entry("metadata").or_insert_with(
217 || json!({"$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}),
218 );
219
220 obj.insert(
222 "x-kubernetes-group-version-kind".to_string(),
223 json!([{ "group": group, "kind": kind, "version": version }]),
224 );
225
226 obj.entry("type").or_insert_with(|| json!("object"));
228
229 schema
230}
231
232fn reverse_domain(group: &str) -> String {
236 let parts: Vec<&str> = group.split('.').collect();
237 let reversed: Vec<&str> = parts.into_iter().rev().collect();
238 reversed.join(".")
239}
240
241fn to_pascal_case(s: &str) -> String {
244 let mut result = String::with_capacity(s.len());
245 let mut capitalize_next = true;
246 for ch in s.chars() {
247 if ch == '_' || ch == '-' {
248 capitalize_next = true;
249 } else if capitalize_next {
250 result.push(ch.to_ascii_uppercase());
251 capitalize_next = false;
252 } else {
253 result.push(ch);
254 }
255 }
256 result
257}
258
259fn crd_err(msg: String) -> OpenApiError {
260 OpenApiError::Crd(msg)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 const SIMPLE_CRD: &str = r#"
268apiVersion: apiextensions.k8s.io/v1
269kind: CustomResourceDefinition
270metadata:
271 name: certificates.cert-manager.io
272spec:
273 group: cert-manager.io
274 names:
275 kind: Certificate
276 plural: certificates
277 scope: Namespaced
278 versions:
279 - name: v1
280 served: true
281 storage: true
282 schema:
283 openAPIV3Schema:
284 type: object
285 properties:
286 spec:
287 type: object
288 properties:
289 secretName:
290 type: string
291 description: Name of the Secret resource.
292 issuerRef:
293 type: object
294 description: Reference to the issuer.
295 properties:
296 name:
297 type: string
298 kind:
299 type: string
300 group:
301 type: string
302 required:
303 - name
304 duration:
305 type: string
306 isCA:
307 type: boolean
308 required:
309 - secretName
310 - issuerRef
311 status:
312 type: object
313 properties:
314 ready:
315 type: boolean
316 conditions:
317 type: array
318 items:
319 type: object
320 properties:
321 type:
322 type: string
323 status:
324 type: string
325 required:
326 - type
327 - status
328"#;
329
330 #[test]
331 fn simple_crd_conversion() {
332 let result = crd_yaml_to_openapi(SIMPLE_CRD).unwrap();
333 let schemas = &result["components"]["schemas"];
334
335 let cert = &schemas["io.cert-manager.v1.Certificate"];
337 assert!(cert["x-kubernetes-group-version-kind"].is_array());
338 let gvk = &cert["x-kubernetes-group-version-kind"][0];
339 assert_eq!(gvk["group"], "cert-manager.io");
340 assert_eq!(gvk["kind"], "Certificate");
341 assert_eq!(gvk["version"], "v1");
342
343 assert!(cert["properties"]["apiVersion"]["type"].is_string());
345 assert!(cert["properties"]["kind"]["type"].is_string());
346 assert!(cert["properties"]["metadata"]["$ref"].is_string());
347 }
348
349 #[test]
350 fn nested_extraction() {
351 let result = crd_yaml_to_openapi(SIMPLE_CRD).unwrap();
352 let schemas = &result["components"]["schemas"];
353
354 let cert = &schemas["io.cert-manager.v1.Certificate"];
356 assert!(
357 cert["properties"]["spec"]["$ref"]
358 .as_str()
359 .unwrap()
360 .contains("CertificateSpec")
361 );
362
363 let spec = &schemas["io.cert-manager.v1.CertificateSpec"];
365 assert_eq!(spec["properties"]["secretName"]["type"], "string");
366 assert_eq!(spec["properties"]["isCA"]["type"], "boolean");
367 assert!(spec["required"].as_array().unwrap().len() >= 2);
368
369 assert!(
371 spec["properties"]["issuerRef"]["$ref"]
372 .as_str()
373 .unwrap()
374 .contains("IssuerRef")
375 );
376 }
377
378 #[test]
379 fn array_items_extraction() {
380 let result = crd_yaml_to_openapi(SIMPLE_CRD).unwrap();
381 let schemas = &result["components"]["schemas"];
382
383 let status = &schemas["io.cert-manager.v1.CertificateStatus"];
385 assert!(status.is_object());
386
387 let conditions = &status["properties"]["conditions"];
389 assert_eq!(conditions["type"], "array");
390 assert!(conditions["items"]["$ref"].as_str().is_some());
391 }
392
393 #[test]
394 fn gvk_present_on_resource() {
395 let result = crd_yaml_to_openapi(SIMPLE_CRD).unwrap();
396 let cert = &result["components"]["schemas"]["io.cert-manager.v1.Certificate"];
397 let gvk = cert["x-kubernetes-group-version-kind"].as_array().unwrap();
398 assert_eq!(gvk.len(), 1);
399 assert_eq!(gvk[0]["group"], "cert-manager.io");
400 }
401
402 #[test]
403 fn multi_version_crd() {
404 let yaml = r#"
405apiVersion: apiextensions.k8s.io/v1
406kind: CustomResourceDefinition
407metadata:
408 name: widgets.example.com
409spec:
410 group: example.com
411 names:
412 kind: Widget
413 plural: widgets
414 scope: Namespaced
415 versions:
416 - name: v1
417 served: true
418 storage: true
419 schema:
420 openAPIV3Schema:
421 type: object
422 properties:
423 spec:
424 type: object
425 properties:
426 size:
427 type: integer
428 - name: v1beta1
429 served: true
430 storage: false
431 schema:
432 openAPIV3Schema:
433 type: object
434 properties:
435 spec:
436 type: object
437 properties:
438 count:
439 type: integer
440"#;
441 let result = crd_yaml_to_openapi(yaml).unwrap();
442 let schemas = &result["components"]["schemas"];
443
444 assert!(schemas["com.example.v1.Widget"].is_object());
445 assert!(schemas["com.example.v1beta1.Widget"].is_object());
446 assert!(schemas["com.example.v1.WidgetSpec"]["properties"]["size"].is_object());
447 assert!(schemas["com.example.v1beta1.WidgetSpec"]["properties"]["count"].is_object());
448 }
449
450 #[test]
451 fn multi_doc_yaml() {
452 let yaml = r#"
453apiVersion: v1
454kind: Namespace
455metadata:
456 name: test
457---
458apiVersion: apiextensions.k8s.io/v1
459kind: CustomResourceDefinition
460metadata:
461 name: things.test.io
462spec:
463 group: test.io
464 names:
465 kind: Thing
466 plural: things
467 scope: Namespaced
468 versions:
469 - name: v1
470 served: true
471 storage: true
472 schema:
473 openAPIV3Schema:
474 type: object
475 properties:
476 spec:
477 type: object
478 properties:
479 value:
480 type: string
481---
482apiVersion: apiextensions.k8s.io/v1
483kind: CustomResourceDefinition
484metadata:
485 name: gadgets.test.io
486spec:
487 group: test.io
488 names:
489 kind: Gadget
490 plural: gadgets
491 scope: Namespaced
492 versions:
493 - name: v1
494 served: true
495 storage: true
496 schema:
497 openAPIV3Schema:
498 type: object
499 properties:
500 spec:
501 type: object
502 properties:
503 name:
504 type: string
505"#;
506 let result = crd_yaml_to_openapi(yaml).unwrap();
507 let schemas = &result["components"]["schemas"];
508
509 assert!(schemas.get("Namespace").is_none());
511
512 assert!(schemas["io.test.v1.Thing"].is_object());
514 assert!(schemas["io.test.v1.Gadget"].is_object());
515 }
516
517 #[test]
518 fn non_crd_only_returns_error() {
519 let yaml = r#"
520apiVersion: v1
521kind: Namespace
522metadata:
523 name: test
524"#;
525 let err = crd_yaml_to_openapi(yaml).unwrap_err();
526 assert!(err.to_string().contains("no valid CRD"));
527 }
528
529 #[test]
530 fn metadata_ref_added() {
531 let result = crd_yaml_to_openapi(SIMPLE_CRD).unwrap();
532 let cert = &result["components"]["schemas"]["io.cert-manager.v1.Certificate"];
533 let meta_ref = cert["properties"]["metadata"]["$ref"].as_str().unwrap();
534 assert!(meta_ref.contains("ObjectMeta"));
535 }
536
537 #[test]
538 fn reverse_domain_conversion() {
539 assert_eq!(reverse_domain("cert-manager.io"), "io.cert-manager");
540 assert_eq!(reverse_domain("postgresql.cnpg.io"), "io.cnpg.postgresql");
541 assert_eq!(
542 reverse_domain("kustomize.toolkit.fluxcd.io"),
543 "io.fluxcd.toolkit.kustomize"
544 );
545 assert_eq!(reverse_domain("example.com"), "com.example");
546 }
547
548 #[test]
549 fn pascal_case_conversion() {
550 assert_eq!(to_pascal_case("spec"), "Spec");
551 assert_eq!(to_pascal_case("privateKey"), "PrivateKey");
552 assert_eq!(to_pascal_case("dns_names"), "DnsNames");
553 assert_eq!(to_pascal_case("issuerRef"), "IssuerRef");
554 }
555}