Skip to main content

alembic_adapter_netbox/netbox/
mod.rs

1//! netbox adapter implementation.
2
3mod client;
4mod mapping;
5mod ops;
6mod registry;
7mod state;
8
9use anyhow::Result;
10
11#[cfg(test)]
12use alembic_engine::Adapter;
13#[cfg(test)]
14use alembic_engine::StateStore;
15use client::NetBoxClient;
16
17/// netbox adapter that maps ir objects to netbox api calls.
18pub struct NetBoxAdapter {
19    client: NetBoxClient,
20}
21
22impl NetBoxAdapter {
23    /// create a new adapter with url, token, and state store.
24    pub fn new(url: &str, token: &str) -> Result<Self> {
25        let client = NetBoxClient::new(url, token)?;
26        Ok(Self { client })
27    }
28}
29
30#[cfg(test)]
31mod tests {
32    use super::*;
33    use crate::netbox::mapping::{build_tag_inputs, slugify};
34    use alembic_core::{key_string, JsonMap, Key, TypeName, Uid};
35    use alembic_engine::Op;
36    use httpmock::Method::{GET, POST};
37    use httpmock::{Mock, MockServer};
38    use serde_json::json;
39    use tempfile::tempdir;
40    use uuid::Uuid;
41
42    fn uid(value: u128) -> Uid {
43        Uuid::from_u128(value)
44    }
45
46    fn attrs_map(value: serde_json::Value) -> JsonMap {
47        let serde_json::Value::Object(map) = value else {
48            panic!("attrs must be a json object");
49        };
50        map.into_iter()
51            .collect::<std::collections::BTreeMap<_, _>>()
52            .into()
53    }
54
55    fn key(field: &str, value: serde_json::Value) -> Key {
56        let mut map = std::collections::BTreeMap::new();
57        map.insert(field.to_string(), value);
58        Key::from(map)
59    }
60
61    fn obj(uid: Uid, type_name: &str, key: Key, attrs: serde_json::Value) -> alembic_core::Object {
62        alembic_core::Object::new(uid, TypeName::new(type_name), key, attrs_map(attrs)).unwrap()
63    }
64
65    fn page(results: serde_json::Value) -> serde_json::Value {
66        json!({
67            "count": results.as_array().map(|a| a.len()).unwrap_or(0),
68            "next": null,
69            "previous": null,
70            "results": results
71        })
72    }
73
74    fn state_with_mappings(path: &std::path::Path) -> StateStore {
75        let mut store = StateStore::load(path).unwrap();
76        store.set_backend_id(
77            TypeName::new("dcim.site"),
78            uid(1),
79            alembic_engine::BackendId::Int(1),
80        );
81        store
82    }
83
84    fn mock_list<'a>(
85        server: &'a MockServer,
86        path: &'a str,
87        payload: serde_json::Value,
88    ) -> Mock<'a> {
89        server.mock(|when, then| {
90            when.method(GET)
91                .path(path)
92                .query_param("limit", "200")
93                .query_param("offset", "0");
94            then.status(200).json_body(page(payload));
95        })
96    }
97
98    #[tokio::test]
99    async fn observe_maps_nested_refs_to_uids() {
100        let server = MockServer::start();
101        let dir = tempdir().unwrap();
102        let state = state_with_mappings(&dir.path().join("state.json"));
103        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
104
105        let _object_types = mock_list(
106            &server,
107            "/api/core/object-types/",
108            json!([
109                {
110                    "app_label": "dcim",
111                    "model": "device",
112                    "rest_api_endpoint": "/api/dcim/devices/",
113                    "features": ["custom-fields", "tags"]
114                },
115                {
116                    "app_label": "dcim",
117                    "model": "site",
118                    "rest_api_endpoint": "/api/dcim/sites/",
119                    "features": ["custom-fields", "tags"]
120                }
121            ]),
122        );
123        let _devices = mock_list(
124            &server,
125            "/api/dcim/devices/",
126            json!([
127                {
128                    "id": 2,
129                    "name": "leaf01",
130                    "site": {
131                        "id": 1,
132                        "url": "https://netbox.example.com/api/dcim/sites/1/",
133                        "name": "FRA1",
134                        "slug": "fra1"
135                    }
136                }
137            ]),
138        );
139        let _custom_fields = server.mock(|when, then| {
140            when.method(GET).path("/api/extras/custom-fields/");
141            then.status(200).json_body(page(json!([])));
142        });
143        let _tags = server.mock(|when, then| {
144            when.method(GET)
145                .path("/api/extras/tags/")
146                .query_param("limit", "200")
147                .query_param("offset", "0");
148            then.status(200)
149                .json_body(page(json!([{"id": 1, "name": "fabric", "slug": "fabric"}])));
150        });
151
152        let schema = alembic_core::Schema {
153            types: std::collections::BTreeMap::from([
154                (
155                    "dcim.device".to_string(),
156                    alembic_core::TypeSchema {
157                        key: std::collections::BTreeMap::from([(
158                            "name".to_string(),
159                            alembic_core::FieldSchema {
160                                r#type: alembic_core::FieldType::String,
161                                required: true,
162                                nullable: false,
163                                description: None,
164                                format: None,
165                                pattern: None,
166                            },
167                        )]),
168                        fields: std::collections::BTreeMap::new(),
169                    },
170                ),
171                (
172                    "dcim.site".to_string(),
173                    alembic_core::TypeSchema {
174                        key: std::collections::BTreeMap::from([(
175                            "name".to_string(),
176                            alembic_core::FieldSchema {
177                                r#type: alembic_core::FieldType::String,
178                                required: true,
179                                nullable: false,
180                                description: None,
181                                format: None,
182                                pattern: None,
183                            },
184                        )]),
185                        fields: std::collections::BTreeMap::new(),
186                    },
187                ),
188            ]),
189        };
190        let observed = adapter
191            .read(&schema, &[TypeName::new("dcim.device")], &state)
192            .await
193            .unwrap();
194
195        let device = observed
196            .by_key
197            .get(&(
198                TypeName::new("dcim.device"),
199                key_string(&key("name", json!("leaf01"))),
200            ))
201            .unwrap();
202        let site_uid = uid(1).to_string();
203        assert_eq!(
204            device.attrs.get("name").and_then(|v| v.as_str()),
205            Some("leaf01")
206        );
207        assert_eq!(
208            device.attrs.get("site").and_then(|v| v.as_str()),
209            Some(site_uid.as_str())
210        );
211    }
212
213    #[tokio::test]
214    async fn apply_orders_creates_by_dependency() {
215        let server = MockServer::start();
216        let dir = tempdir().unwrap();
217        let state = StateStore::load(dir.path().join("state.json")).unwrap();
218        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
219
220        let _object_types = mock_list(
221            &server,
222            "/api/core/object-types/",
223            json!([
224                {
225                    "app_label": "dcim",
226                    "model": "site",
227                    "rest_api_endpoint": "/api/dcim/sites/",
228                    "features": ["custom-fields", "tags"]
229                },
230                {
231                    "app_label": "dcim",
232                    "model": "device",
233                    "rest_api_endpoint": "/api/dcim/devices/",
234                    "features": ["custom-fields", "tags"]
235                }
236            ]),
237        );
238        let _custom_fields = server.mock(|when, then| {
239            when.method(GET).path("/api/extras/custom-fields/");
240            then.status(200).json_body(page(json!([])));
241        });
242        let _site_create = server.mock(|when, then| {
243            when.method(POST)
244                .path("/api/dcim/sites/")
245                .json_body(json!({ "name": "FRA1", "slug": "fra1" }));
246            then.status(201)
247                .json_body(json!({ "id": 1, "name": "FRA1", "slug": "fra1" }));
248        });
249        let _device_create = server.mock(|when, then| {
250            when.method(POST)
251                .path("/api/dcim/devices/")
252                .json_body(json!({ "name": "leaf01", "site": 1 }));
253            then.status(201)
254                .json_body(json!({ "id": 2, "name": "leaf01" }));
255        });
256
257        let ops = vec![
258            Op::Create {
259                uid: uid(2),
260                type_name: TypeName::new("dcim.device"),
261                desired: obj(
262                    uid(2),
263                    "dcim.device",
264                    key("name", json!("leaf01")),
265                    json!({
266                        "name": "leaf01",
267                        "site": uid(1).to_string()
268                    }),
269                ),
270            },
271            Op::Create {
272                uid: uid(1),
273                type_name: TypeName::new("dcim.site"),
274                desired: obj(
275                    uid(1),
276                    "dcim.site",
277                    key("name", json!("fra1")),
278                    json!({ "name": "FRA1", "slug": "fra1" }),
279                ),
280            },
281        ];
282
283        let schema = alembic_core::Schema {
284            types: std::collections::BTreeMap::from([
285                (
286                    "dcim.device".to_string(),
287                    alembic_core::TypeSchema {
288                        key: std::collections::BTreeMap::from([(
289                            "name".to_string(),
290                            alembic_core::FieldSchema {
291                                r#type: alembic_core::FieldType::String,
292                                required: true,
293                                nullable: false,
294                                description: None,
295                                format: None,
296                                pattern: None,
297                            },
298                        )]),
299                        fields: std::collections::BTreeMap::from([
300                            (
301                                "name".to_string(),
302                                alembic_core::FieldSchema {
303                                    r#type: alembic_core::FieldType::String,
304                                    required: true,
305                                    nullable: false,
306                                    description: None,
307                                    format: None,
308                                    pattern: None,
309                                },
310                            ),
311                            (
312                                "site".to_string(),
313                                alembic_core::FieldSchema {
314                                    r#type: alembic_core::FieldType::Ref {
315                                        target: "dcim.site".to_string(),
316                                    },
317                                    required: true,
318                                    nullable: false,
319                                    description: None,
320                                    format: None,
321                                    pattern: None,
322                                },
323                            ),
324                        ]),
325                    },
326                ),
327                (
328                    "dcim.site".to_string(),
329                    alembic_core::TypeSchema {
330                        key: std::collections::BTreeMap::from([(
331                            "name".to_string(),
332                            alembic_core::FieldSchema {
333                                r#type: alembic_core::FieldType::String,
334                                required: true,
335                                nullable: false,
336                                description: None,
337                                format: None,
338                                pattern: None,
339                            },
340                        )]),
341                        fields: std::collections::BTreeMap::from([
342                            (
343                                "name".to_string(),
344                                alembic_core::FieldSchema {
345                                    r#type: alembic_core::FieldType::String,
346                                    required: true,
347                                    nullable: false,
348                                    description: None,
349                                    format: None,
350                                    pattern: None,
351                                },
352                            ),
353                            (
354                                "slug".to_string(),
355                                alembic_core::FieldSchema {
356                                    r#type: alembic_core::FieldType::String,
357                                    required: true,
358                                    nullable: false,
359                                    description: None,
360                                    format: None,
361                                    pattern: None,
362                                },
363                            ),
364                        ]),
365                    },
366                ),
367            ]),
368        };
369        let report = adapter.write(&schema, &ops, &state).await.unwrap();
370        assert_eq!(report.applied.len(), 2);
371    }
372
373    #[tokio::test]
374    async fn apply_creates_missing_tags() {
375        let server = MockServer::start();
376        let dir = tempdir().unwrap();
377        let state = StateStore::load(dir.path().join("state.json")).unwrap();
378        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
379
380        let _object_types = mock_list(
381            &server,
382            "/api/core/object-types/",
383            json!([{
384                "app_label": "dcim",
385                "model": "site",
386                "rest_api_endpoint": "/api/dcim/sites/",
387                "features": ["custom-fields", "tags"]
388            }]),
389        );
390        let _custom_fields = server.mock(|when, then| {
391            when.method(GET).path("/api/extras/custom-fields/");
392            then.status(200).json_body(page(json!([])));
393        });
394        let _tags = server.mock(|when, then| {
395            when.method(GET)
396                .path("/api/extras/tags/")
397                .query_param("limit", "200")
398                .query_param("offset", "0");
399            then.status(200).json_body(page(json!([])));
400        });
401        let _tag_create = server.mock(|when, then| {
402            when.method(POST).path("/api/extras/tags/");
403            then.status(201)
404                .json_body(json!({"id": 1, "name": "fabric", "slug": "fabric"}));
405        });
406        let _site_create = server.mock(|when, then| {
407            when.method(POST).path("/api/dcim/sites/");
408            then.status(201)
409                .json_body(json!({ "id": 1, "name": "FRA1", "slug": "fra1" }));
410        });
411
412        let mut attrs = std::collections::BTreeMap::new();
413        attrs.insert("name".to_string(), json!("FRA1"));
414        attrs.insert("slug".to_string(), json!("fra1"));
415        attrs.insert("tags".to_string(), json!(["fabric"]));
416
417        let ops = vec![Op::Create {
418            uid: uid(1),
419            type_name: TypeName::new("dcim.site"),
420            desired: alembic_core::Object {
421                uid: uid(1),
422                type_name: TypeName::new("dcim.site"),
423                key: Key::default(),
424                attrs: alembic_core::JsonMap::from(attrs),
425                source: None,
426            },
427        }];
428
429        let schema = alembic_core::Schema {
430            types: std::collections::BTreeMap::from([(
431                "dcim.site".to_string(),
432                alembic_core::TypeSchema {
433                    key: std::collections::BTreeMap::new(),
434                    fields: std::collections::BTreeMap::from([
435                        (
436                            "name".to_string(),
437                            alembic_core::FieldSchema {
438                                r#type: alembic_core::FieldType::String,
439                                required: true,
440                                nullable: false,
441                                description: None,
442                                format: None,
443                                pattern: None,
444                            },
445                        ),
446                        (
447                            "slug".to_string(),
448                            alembic_core::FieldSchema {
449                                r#type: alembic_core::FieldType::String,
450                                required: true,
451                                nullable: false,
452                                description: None,
453                                format: None,
454                                pattern: None,
455                            },
456                        ),
457                        (
458                            "tags".to_string(),
459                            alembic_core::FieldSchema {
460                                r#type: alembic_core::FieldType::List {
461                                    item: Box::new(alembic_core::FieldType::String),
462                                },
463                                required: false,
464                                nullable: true,
465                                description: None,
466                                format: None,
467                                pattern: None,
468                            },
469                        ),
470                    ]),
471                },
472            )]),
473        };
474
475        let report = adapter.write(&schema, &ops, &state).await.unwrap();
476        assert_eq!(report.applied.len(), 1);
477    }
478
479    #[test]
480    fn slugify_normalizes_value() {
481        assert_eq!(slugify("EVPN Fabric"), "evpn-fabric");
482        assert_eq!(slugify("edge--core"), "edge-core");
483    }
484
485    #[test]
486    fn build_tag_inputs_uses_slugify() {
487        let tags = vec!["EVPN Fabric".to_string()];
488        let inputs = build_tag_inputs(&tags);
489        assert_eq!(inputs.len(), 1);
490        assert_eq!(inputs[0].name, "EVPN Fabric");
491        assert_eq!(inputs[0].slug, "evpn-fabric");
492    }
493
494    #[tokio::test]
495    async fn apply_handles_update_operation() {
496        use alembic_engine::FieldChange;
497        use httpmock::Method::PATCH;
498
499        let server = MockServer::start();
500        let dir = tempdir().unwrap();
501        let mut state = StateStore::load(dir.path().join("state.json")).unwrap();
502        state.set_backend_id(
503            TypeName::new("dcim.site"),
504            uid(1),
505            alembic_engine::BackendId::Int(1),
506        );
507        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
508
509        let _object_types = mock_list(
510            &server,
511            "/api/core/object-types/",
512            json!([{
513                "app_label": "dcim",
514                "model": "site",
515                "rest_api_endpoint": "/api/dcim/sites/",
516                "features": ["custom-fields", "tags"]
517            }]),
518        );
519        let _custom_fields = server.mock(|when, then| {
520            when.method(GET).path("/api/extras/custom-fields/");
521            then.status(200).json_body(page(json!([])));
522        });
523        let _site_update = server.mock(|when, then| {
524            when.method(PATCH).path("/api/dcim/sites/1/");
525            then.status(200)
526                .json_body(json!({ "id": 1, "name": "FRA1-Updated", "slug": "fra1" }));
527        });
528
529        let mut key = std::collections::BTreeMap::new();
530        key.insert("slug".to_string(), json!("fra1"));
531        let mut attrs = std::collections::BTreeMap::new();
532        attrs.insert("name".to_string(), json!("FRA1-Updated"));
533
534        let ops = vec![Op::Update {
535            uid: uid(1),
536            type_name: TypeName::new("dcim.site"),
537            backend_id: Some(alembic_engine::BackendId::Int(1)),
538            desired: alembic_core::Object {
539                uid: uid(1),
540                type_name: TypeName::new("dcim.site"),
541                key: alembic_core::Key::from(key),
542                attrs: alembic_core::JsonMap::from(attrs),
543                source: None,
544            },
545            changes: vec![FieldChange {
546                field: "name".to_string(),
547                from: json!("FRA1"),
548                to: json!("FRA1-Updated"),
549            }],
550        }];
551
552        let schema = alembic_core::Schema {
553            types: std::collections::BTreeMap::from([(
554                "dcim.site".to_string(),
555                alembic_core::TypeSchema {
556                    key: std::collections::BTreeMap::from([(
557                        "slug".to_string(),
558                        alembic_core::FieldSchema {
559                            r#type: alembic_core::FieldType::String,
560                            required: true,
561                            nullable: false,
562                            description: None,
563                            format: None,
564                            pattern: None,
565                        },
566                    )]),
567                    fields: std::collections::BTreeMap::from([(
568                        "name".to_string(),
569                        alembic_core::FieldSchema {
570                            r#type: alembic_core::FieldType::String,
571                            required: true,
572                            nullable: false,
573                            description: None,
574                            format: None,
575                            pattern: None,
576                        },
577                    )]),
578                },
579            )]),
580        };
581        let report = adapter.write(&schema, &ops, &state).await.unwrap();
582        assert_eq!(report.applied.len(), 1);
583    }
584
585    #[tokio::test]
586    async fn apply_handles_delete_operation() {
587        use httpmock::Method::DELETE;
588
589        let server = MockServer::start();
590        let dir = tempdir().unwrap();
591        let mut state = StateStore::load(dir.path().join("state.json")).unwrap();
592        state.set_backend_id(
593            TypeName::new("dcim.site"),
594            uid(1),
595            alembic_engine::BackendId::Int(1),
596        );
597        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
598
599        let _object_types = mock_list(
600            &server,
601            "/api/core/object-types/",
602            json!([{
603                "app_label": "dcim",
604                "model": "site",
605                "rest_api_endpoint": "/api/dcim/sites/",
606                "features": ["custom-fields", "tags"]
607            }]),
608        );
609        let _custom_fields = server.mock(|when, then| {
610            when.method(GET).path("/api/extras/custom-fields/");
611            then.status(200).json_body(page(json!([])));
612        });
613        let _site_delete = server.mock(|when, then| {
614            when.method(DELETE).path("/api/dcim/sites/");
615            then.status(204);
616        });
617
618        let ops = vec![Op::Delete {
619            uid: uid(1),
620            type_name: TypeName::new("dcim.site"),
621            key: key("slug", json!("fra1")),
622            backend_id: Some(alembic_engine::BackendId::Int(1)),
623        }];
624
625        let schema = alembic_core::Schema {
626            types: std::collections::BTreeMap::from([(
627                "dcim.site".to_string(),
628                alembic_core::TypeSchema {
629                    key: std::collections::BTreeMap::from([(
630                        "slug".to_string(),
631                        alembic_core::FieldSchema {
632                            r#type: alembic_core::FieldType::String,
633                            required: true,
634                            nullable: false,
635                            description: None,
636                            format: None,
637                            pattern: None,
638                        },
639                    )]),
640                    fields: std::collections::BTreeMap::new(),
641                },
642            )]),
643        };
644        let report = adapter.write(&schema, &ops, &state).await.unwrap();
645        assert_eq!(report.applied.len(), 1);
646    }
647
648    #[tokio::test]
649    async fn observe_handles_empty_types_list() {
650        let server = MockServer::start();
651        let dir = tempdir().unwrap();
652        let state = StateStore::load(dir.path().join("state.json")).unwrap();
653        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
654
655        let _object_types = mock_list(
656            &server,
657            "/api/core/object-types/",
658            json!([{
659                "app_label": "dcim",
660                "model": "site",
661                "rest_api_endpoint": "/api/dcim/sites/",
662                "features": ["custom-fields", "tags"]
663            }]),
664        );
665        let _sites = mock_list(&server, "/api/dcim/sites/", json!([]));
666        let _custom_fields = server.mock(|when, then| {
667            when.method(GET).path("/api/extras/custom-fields/");
668            then.status(200).json_body(page(json!([])));
669        });
670        let _tags = server.mock(|when, then| {
671            when.method(GET)
672                .path("/api/extras/tags/")
673                .query_param("limit", "200")
674                .query_param("offset", "0");
675            then.status(200).json_body(page(json!([])));
676        });
677
678        let schema = alembic_core::Schema {
679            types: std::collections::BTreeMap::from([(
680                "dcim.site".to_string(),
681                alembic_core::TypeSchema {
682                    key: std::collections::BTreeMap::from([(
683                        "slug".to_string(),
684                        alembic_core::FieldSchema {
685                            r#type: alembic_core::FieldType::String,
686                            required: true,
687                            nullable: false,
688                            description: None,
689                            format: None,
690                            pattern: None,
691                        },
692                    )]),
693                    fields: std::collections::BTreeMap::new(),
694                },
695            )]),
696        };
697        // Empty types list should observe all types from registry
698        let observed = adapter.read(&schema, &[], &state).await.unwrap();
699        assert!(observed.by_key.is_empty());
700    }
701
702    #[tokio::test]
703    async fn ensure_schema_creates_custom_fields() {
704        let server = MockServer::start();
705        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
706
707        let _object_types = mock_list(
708            &server,
709            "/api/core/object-types/",
710            json!([{
711                "app_label": "dcim",
712                "model": "site",
713                "rest_api_endpoint": "/api/dcim/sites/",
714                "features": ["custom-fields", "tags"]
715            }]),
716        );
717        let _custom_fields = server.mock(|when, then| {
718            when.method(GET).path("/api/extras/custom-fields/");
719            then.status(200).json_body(page(json!([])));
720        });
721        let _sites = server.mock(|when, then| {
722            when.method(GET)
723                .path("/api/dcim/sites/")
724                .query_param("limit", "1");
725            then.status(200).json_body(page(json!([])));
726        });
727        let _cf_create = server.mock(|when, then| {
728            when.method(POST).path("/api/extras/custom-fields/");
729            then.status(201).json_body(json!({
730                "id": 1,
731                "name": "cf_test",
732                "object_types": ["dcim.site"],
733                "type": {"value": "text", "label": "Text"}
734            }));
735        });
736
737        let schema = alembic_core::Schema {
738            types: std::collections::BTreeMap::from([(
739                "dcim.site".to_string(),
740                alembic_core::TypeSchema {
741                    key: std::collections::BTreeMap::from([(
742                        "slug".to_string(),
743                        alembic_core::FieldSchema {
744                            r#type: alembic_core::FieldType::String,
745                            required: true,
746                            nullable: false,
747                            description: None,
748                            format: None,
749                            pattern: None,
750                        },
751                    )]),
752                    fields: std::collections::BTreeMap::from([(
753                        "cf_test".to_string(),
754                        alembic_core::FieldSchema {
755                            r#type: alembic_core::FieldType::String,
756                            required: false,
757                            nullable: true,
758                            description: None,
759                            format: None,
760                            pattern: None,
761                        },
762                    )]),
763                },
764            )]),
765        };
766
767        let report = adapter.ensure_schema(&schema).await.unwrap();
768        assert_eq!(report.created_fields, vec!["dcim.site.cf_test".to_string()]);
769    }
770
771    #[tokio::test]
772    async fn ensure_schema_creates_custom_object_types() {
773        let server = MockServer::start();
774        let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
775
776        let _object_types = mock_list(
777            &server,
778            "/api/core/object-types/",
779            json!([{
780                "app_label": "dcim",
781                "model": "site",
782                "rest_api_endpoint": "/api/dcim/sites/",
783                "features": ["custom-fields", "tags"]
784            }]),
785        );
786        let _custom_fields = server.mock(|when, then| {
787            when.method(GET).path("/api/extras/custom-fields/");
788            then.status(200).json_body(page(json!([])));
789        });
790        let _custom_object_types = server.mock(|when, then| {
791            when.method(GET)
792                .path("/api/plugins/custom-objects/custom-object-types/")
793                .query_param("limit", "200")
794                .query_param("offset", "0");
795            then.status(200).json_body(page(json!([])));
796        });
797        let _custom_object_fields = server.mock(|when, then| {
798            when.method(GET)
799                .path("/api/plugins/custom-objects/custom-object-type-fields/")
800                .query_param("limit", "200")
801                .query_param("offset", "0");
802            then.status(200).json_body(page(json!([])));
803        });
804        let _custom_object_type_create = server.mock(|when, then| {
805            when.method(POST)
806                .path("/api/plugins/custom-objects/custom-object-types/");
807            then.status(201).json_body(json!({
808                "id": 42,
809                "name": "custom-asset"
810            }));
811        });
812        let _custom_object_field_create = server.mock(|when, then| {
813            when.method(POST)
814                .path("/api/plugins/custom-objects/custom-object-type-fields/");
815            then.status(201).json_body(json!({
816                "id": 100
817            }));
818        });
819
820        let schema = alembic_core::Schema {
821            types: std::collections::BTreeMap::from([(
822                "custom.asset".to_string(),
823                alembic_core::TypeSchema {
824                    key: std::collections::BTreeMap::from([(
825                        "name".to_string(),
826                        alembic_core::FieldSchema {
827                            r#type: alembic_core::FieldType::String,
828                            required: true,
829                            nullable: false,
830                            description: None,
831                            format: None,
832                            pattern: None,
833                        },
834                    )]),
835                    fields: std::collections::BTreeMap::from([(
836                        "owner".to_string(),
837                        alembic_core::FieldSchema {
838                            r#type: alembic_core::FieldType::String,
839                            required: false,
840                            nullable: true,
841                            description: None,
842                            format: None,
843                            pattern: None,
844                        },
845                    )]),
846                },
847            )]),
848        };
849
850        let report = adapter.ensure_schema(&schema).await.unwrap();
851        assert_eq!(
852            report.created_object_types,
853            vec!["custom.asset".to_string()]
854        );
855        assert!(report
856            .created_object_fields
857            .contains(&"custom.asset.name".to_string()));
858        assert!(report
859            .created_object_fields
860            .contains(&"custom.asset.owner".to_string()));
861    }
862}