Skip to main content

husako_openapi/
crd.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value, json};
4
5use crate::OpenApiError;
6
7/// Convert CRD YAML (one or more documents) to the OpenAPI JSON format
8/// expected by `husako-dts`.
9///
10/// Returns `{"components": {"schemas": { ... }}}` with all extracted schemas.
11pub 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
37/// Check if a YAML document is a CRD (`apiextensions.k8s.io/v1`).
38fn 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
50/// Extract schemas from a single CRD document.
51fn 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        // Extract nested schemas from the openAPIV3Schema
87        let mut extracted = BTreeMap::new();
88        let top_schema = extract_nested_schemas(raw_schema, kind, &base, &mut extracted)?;
89
90        // Build the top-level resource schema
91        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        // Insert all extracted sub-schemas
96        schemas.extend(extracted);
97    }
98
99    Ok(())
100}
101
102/// Recursively extract nested object properties into separate named schemas.
103///
104/// Returns the transformed schema with nested objects replaced by `$ref`.
105fn 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    // Process properties
118    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    // Process array items
129    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
137/// Decide whether a property schema should be extracted into a separate named schema.
138fn 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    // Extract inline objects with properties
150    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        // Preserve description from the property
157        let desc = obj.get("description").cloned();
158
159        extracted.insert(full_name.clone(), sub_schema);
160
161        // Return a $ref (optionally with description)
162        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    // Recurse into array items
171    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
192/// An object is extractable if it has type "object" and has named properties.
193fn 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
198/// Build the top-level resource schema with standard Kubernetes fields.
199fn build_resource_schema(mut schema: Value, group: &str, version: &str, kind: &str) -> Value {
200    let obj = schema.as_object_mut().unwrap();
201
202    // Ensure properties map exists
203    let props = obj
204        .entry("properties")
205        .or_insert_with(|| json!({}))
206        .as_object_mut()
207        .unwrap();
208
209    // Add apiVersion/kind/metadata if not present
210    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    // Add x-kubernetes-group-version-kind
221    obj.insert(
222        "x-kubernetes-group-version-kind".to_string(),
223        json!([{ "group": group, "kind": kind, "version": version }]),
224    );
225
226    // Ensure type is "object"
227    obj.entry("type").or_insert_with(|| json!("object"));
228
229    schema
230}
231
232/// Reverse a domain name for schema naming.
233/// `cert-manager.io` → `io.cert-manager`
234/// `postgresql.cnpg.io` → `io.cnpg.postgresql`
235fn 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
241/// Convert a string to PascalCase.
242/// `spec` → `Spec`, `privateKey` → `PrivateKey`, `dns_names` → `DnsNames`
243fn 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        // Top-level resource exists with GVK
336        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        // Has apiVersion, kind, metadata
344        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        // Spec was extracted
355        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        // Spec schema exists
364        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        // Nested issuerRef was extracted
370        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        // Status was extracted
384        let status = &schemas["io.cert-manager.v1.CertificateStatus"];
385        assert!(status.is_object());
386
387        // conditions array items were extracted
388        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        // Non-CRD documents are skipped
510        assert!(schemas.get("Namespace").is_none());
511
512        // Both CRDs are processed
513        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}