Skip to main content

alembic_adapter_netbox/netbox/
ops.rs

1use super::client::CustomObjectType;
2use super::mapping::{build_tag_inputs, custom_field_type_for_schema, slugify, tags_from_value};
3use super::registry::ObjectTypeRegistry;
4use super::state::{resolved_from_state, state_mappings};
5use super::NetBoxAdapter;
6use alembic_core::{
7    key_string, uid_v5, FieldSchema, FieldType, JsonMap, Key, Schema, TypeName, TypeSchema, Uid,
8};
9use alembic_engine::{
10    apply_non_delete_with_retries, build_key_from_schema, query_filters_from_key, Adapter,
11    AdapterApplyError, AppliedOp, ApplyReport, BackendId, ObservedObject, ObservedState, Op,
12    ProvisionReport, RetryApplyDriver,
13};
14use anyhow::{anyhow, Context, Result};
15use async_trait::async_trait;
16use netbox::{BulkDelete, QueryBuilder, Resource};
17use serde_json::{Map, Value};
18use std::collections::{BTreeMap, BTreeSet};
19
20const CUSTOM_OBJECT_FEATURE: &str = "custom-object";
21const CUSTOM_OBJECT_APP_LABEL: &str = "netbox_custom_objects";
22const ALEMBIC_CUSTOM_OBJECT_PREFIX: &str = "alembic custom object for ";
23
24#[async_trait]
25impl Adapter for NetBoxAdapter {
26    async fn read(
27        &self,
28        schema: &Schema,
29        types: &[TypeName],
30        state_store: &alembic_engine::StateStore,
31    ) -> Result<ObservedState> {
32        let registry: ObjectTypeRegistry = build_registry_for_schema(self, schema).await?;
33        let mut state = ObservedState::default();
34        let mappings = state_mappings(state_store);
35
36        let requested: BTreeSet<TypeName> = if types.is_empty() {
37            registry.type_names().into_iter().collect()
38        } else {
39            types.iter().cloned().collect()
40        };
41
42        for type_name in requested {
43            let info = registry
44                .info_for(&type_name)
45                .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
46            let type_schema = schema
47                .types
48                .get(type_name.as_str())
49                .ok_or_else(|| anyhow!("missing schema for {}", type_name))?;
50            let resource: Resource<Value> = self.client.resource(info.endpoint.clone());
51            let objects = match self.client.list_all(&resource, None).await {
52                Ok(objects) => objects,
53                Err(err)
54                    if is_404_anyhow(&err) && info.features.contains(CUSTOM_OBJECT_FEATURE) =>
55                {
56                    continue;
57                }
58                Err(err) => return Err(err),
59            };
60            for object in objects {
61                let (backend_id, mut attrs) = extract_attrs(object)?;
62                normalize_attrs(&mut attrs, type_schema, schema, &registry, &mappings);
63                let key = build_key_from_schema(type_schema, &attrs)
64                    .with_context(|| format!("build key for {}", type_name))?;
65                state.insert(ObservedObject {
66                    type_name: type_name.clone(),
67                    key,
68                    attrs,
69                    backend_id: Some(BackendId::Int(backend_id)),
70                });
71            }
72        }
73
74        Ok(state)
75    }
76
77    async fn write(
78        &self,
79        schema: &Schema,
80        ops: &[Op],
81        state: &alembic_engine::StateStore,
82    ) -> Result<ApplyReport> {
83        let registry: ObjectTypeRegistry = build_registry_for_schema(self, schema).await?;
84        let custom_fields_by_type = self.client.fetch_custom_fields().await?;
85        let mut applied = Vec::new();
86        let mut resolved = resolved_from_state(state);
87
88        for op in ops {
89            if let Op::Create { uid, .. } = op {
90                resolved.remove(uid);
91            }
92        }
93
94        let tag_names = collect_tag_names(ops, &registry)?;
95        if !tag_names.is_empty() {
96            let mut existing = self.client.fetch_tags().await?;
97            let missing: Vec<String> = tag_names.difference(&existing).cloned().collect();
98            if !missing.is_empty() {
99                self.create_tags(&missing).await?;
100                for tag in missing {
101                    existing.insert(tag);
102                }
103            }
104        }
105
106        let mut creates_updates = Vec::new();
107        let mut deletes = Vec::new();
108        for op in ops {
109            match op {
110                Op::Delete { .. } => deletes.push(op.clone()),
111                _ => creates_updates.push(op.clone()),
112            }
113        }
114
115        struct ApplyDriver<'a> {
116            adapter: &'a NetBoxAdapter,
117            resolved: &'a mut BTreeMap<Uid, u64>,
118            registry: &'a ObjectTypeRegistry,
119            schema: &'a Schema,
120            custom_fields_by_type: &'a BTreeMap<String, BTreeSet<String>>,
121        }
122
123        #[async_trait]
124        impl RetryApplyDriver for ApplyDriver<'_> {
125            async fn apply_non_delete(&mut self, op: &Op) -> Result<AppliedOp> {
126                match op {
127                    Op::Create { .. } => self
128                        .adapter
129                        .apply_create(
130                            op,
131                            self.resolved,
132                            self.registry,
133                            self.schema,
134                            self.custom_fields_by_type,
135                        )
136                        .await
137                        .map(|backend_id| AppliedOp {
138                            uid: op.uid(),
139                            type_name: op.type_name().clone(),
140                            backend_id: Some(BackendId::Int(backend_id)),
141                        }),
142                    Op::Update { .. } => self
143                        .adapter
144                        .apply_update(
145                            op,
146                            self.resolved,
147                            self.registry,
148                            self.schema,
149                            self.custom_fields_by_type,
150                        )
151                        .await
152                        .map(|backend_id| AppliedOp {
153                            uid: op.uid(),
154                            type_name: op.type_name().clone(),
155                            backend_id: Some(BackendId::Int(backend_id)),
156                        }),
157                    Op::Delete { .. } => unreachable!("delete ops filtered before retry"),
158                }
159            }
160
161            fn is_retryable(&self, err: &anyhow::Error) -> bool {
162                is_missing_ref_error(err)
163            }
164        }
165
166        let mut driver = ApplyDriver {
167            adapter: self,
168            resolved: &mut resolved,
169            registry: &registry,
170            schema,
171            custom_fields_by_type: &custom_fields_by_type,
172        };
173        let retry_result = apply_non_delete_with_retries(&creates_updates, &mut driver).await?;
174
175        if !retry_result.pending.is_empty() {
176            let missing = describe_missing_refs(&retry_result.pending, &resolved);
177            return Err(anyhow!("unresolved references: {missing}"));
178        }
179
180        for applied_op in retry_result.applied {
181            if let Some(BackendId::Int(backend_id)) = &applied_op.backend_id {
182                resolved.insert(applied_op.uid, *backend_id);
183            }
184            applied.push(applied_op);
185        }
186
187        for op in deletes {
188            if let Op::Delete {
189                uid,
190                type_name,
191                key,
192                backend_id,
193            } = op
194            {
195                let id = if let Some(BackendId::Int(id)) = backend_id {
196                    id
197                } else if let Some(id) = resolved.get(&uid).copied() {
198                    id
199                } else {
200                    let info = registry
201                        .info_for(&type_name)
202                        .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
203                    let type_schema = schema
204                        .types
205                        .get(type_name.as_str())
206                        .ok_or_else(|| anyhow!("missing schema for {}", type_name))?;
207                    self.lookup_backend_id(&type_name, &info, type_schema, &key, &resolved)
208                        .await
209                        .with_context(|| {
210                            format!("resolve backend id for delete: {}", key_string(&key))
211                        })?
212                };
213                let info = registry
214                    .info_for(&type_name)
215                    .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
216                let resource: Resource<Value> = self.client.resource(info.endpoint.clone());
217                let batch = [BulkDelete::new(id)];
218                match resource.bulk_delete(&batch).await {
219                    Ok(_) => {}
220                    Err(err) if is_404_error(&err) => {
221                        tracing::warn!(type_name = %type_name, "object already deleted");
222                    }
223                    Err(err) => return Err(err.into()),
224                }
225                applied.push(AppliedOp {
226                    uid,
227                    type_name: type_name.clone(),
228                    backend_id: None,
229                });
230            }
231        }
232
233        Ok(ApplyReport {
234            applied,
235            ..Default::default()
236        })
237    }
238
239    async fn ensure_schema(&self, schema: &Schema) -> Result<ProvisionReport> {
240        let mut registry: ObjectTypeRegistry = self.client.fetch_object_types().await?;
241        let custom_fields_by_type = self.client.fetch_custom_fields().await?;
242        let custom_object_types = self.client.fetch_custom_object_types().await?;
243        let custom_object_fields = self.client.fetch_custom_object_type_fields().await?;
244        let custom_objects_available = custom_object_types.is_some();
245
246        let mut created_fields = Vec::new();
247        let created_tags = Vec::new();
248        let mut created_object_types = Vec::new();
249        let mut created_object_fields = Vec::new();
250        let mut deleted_object_types = Vec::new();
251        let mut deleted_object_fields = Vec::new();
252
253        let mut custom_types_by_name: BTreeMap<String, CustomObjectType> = BTreeMap::new();
254        if let Some(types) = custom_object_types {
255            for item in types {
256                custom_types_by_name.insert(item.name.clone(), item);
257            }
258        }
259
260        let mut custom_fields_by_type_id: BTreeMap<u64, BTreeMap<String, u64>> = BTreeMap::new();
261        if let Some(fields) = custom_object_fields {
262            for field in fields {
263                custom_fields_by_type_id
264                    .entry(field.custom_object_type)
265                    .or_default()
266                    .insert(field.name, field.id);
267            }
268        }
269
270        let mut custom_schema_types: Vec<(TypeName, &TypeSchema)> = Vec::new();
271        let mut custom_schema_type_names: BTreeSet<String> = BTreeSet::new();
272        for (type_name, type_schema) in &schema.types {
273            let type_name = TypeName::new(type_name);
274            if registry.contains_type(&type_name) {
275                let info = registry
276                    .info_for(&type_name)
277                    .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
278                if !supports_feature(&info.features, &["custom-fields"]) {
279                    continue;
280                }
281
282                let native_fields = native_fields_for_type(self, &info, type_schema).await?;
283                let existing = custom_fields_by_type
284                    .get(type_name.as_str())
285                    .cloned()
286                    .unwrap_or_default();
287
288                for (field_name, field_schema) in &type_schema.fields {
289                    if matches!(
290                        field_schema.r#type,
291                        FieldType::Ref { .. } | FieldType::ListRef { .. }
292                    ) {
293                        continue;
294                    }
295                    if native_fields.contains(field_name) || existing.contains(field_name) {
296                        continue;
297                    }
298                    if self
299                        .create_custom_field(&type_name, field_name, field_schema)
300                        .await?
301                    {
302                        created_fields.push(format!("{}.{}", type_name, field_name));
303                    }
304                }
305                continue;
306            }
307
308            custom_schema_type_names.insert(type_name.as_str().to_string());
309            custom_schema_types.push((type_name, type_schema));
310        }
311
312        let mut desired_fields_by_type_id: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
313
314        if !custom_schema_types.is_empty() {
315            if !custom_objects_available {
316                let list = custom_schema_types
317                    .iter()
318                    .map(|(name, _)| name.as_str())
319                    .collect::<Vec<_>>()
320                    .join(", ");
321                return Err(anyhow!(
322                    "schema includes custom type(s) but netbox custom objects are not available: {list}"
323                ));
324            }
325
326            let mut custom_type_ids: BTreeMap<String, u64> = BTreeMap::new();
327            for (type_name, _) in &custom_schema_types {
328                let custom_name = custom_object_type_name(type_name);
329                let type_id = if let Some(existing) = custom_types_by_name.get(&custom_name) {
330                    let (app_label, model) =
331                        custom_object_type_parts(existing).unwrap_or_else(|| {
332                            (CUSTOM_OBJECT_APP_LABEL.to_string(), custom_name.clone())
333                        });
334                    registry.insert_custom_object_type(
335                        type_name.clone(),
336                        custom_object_endpoint(&custom_name),
337                        custom_object_features(),
338                        app_label,
339                        model,
340                    );
341                    existing.id
342                } else {
343                    let payload = Map::from_iter([
344                        ("name".to_string(), Value::String(custom_name.clone())),
345                        ("slug".to_string(), Value::String(custom_name.clone())),
346                        (
347                            "description".to_string(),
348                            Value::String(format!(
349                                "alembic custom object for {}",
350                                type_name.as_str()
351                            )),
352                        ),
353                        (
354                            "verbose_name_plural".to_string(),
355                            Value::String(custom_object_verbose_name_plural(type_name)),
356                        ),
357                    ]);
358                    let resource: Resource<Value> = self
359                        .client
360                        .resource("plugins/custom-objects/custom-object-types/");
361                    match resource.create(&Value::Object(payload)).await {
362                        Ok(created) => {
363                            let created_type = super::client::parse_custom_object_type(created)?;
364                            let id = created_type.id;
365                            let (app_label, model) = custom_object_type_parts(&created_type)
366                                .unwrap_or_else(|| {
367                                    (CUSTOM_OBJECT_APP_LABEL.to_string(), custom_name.clone())
368                                });
369                            registry.insert_custom_object_type(
370                                type_name.clone(),
371                                custom_object_endpoint(&custom_name),
372                                custom_object_features(),
373                                app_label,
374                                model,
375                            );
376                            custom_types_by_name.insert(custom_name.clone(), created_type);
377                            created_object_types.push(type_name.to_string());
378                            id
379                        }
380                        Err(err) => {
381                            if let Some(types) = self.client.fetch_custom_object_types().await? {
382                                if let Some(existing) =
383                                    types.into_iter().find(|item| item.name == custom_name)
384                                {
385                                    let (app_label, model) = custom_object_type_parts(&existing)
386                                        .unwrap_or_else(|| {
387                                            (
388                                                CUSTOM_OBJECT_APP_LABEL.to_string(),
389                                                custom_name.clone(),
390                                            )
391                                        });
392                                    registry.insert_custom_object_type(
393                                        type_name.clone(),
394                                        custom_object_endpoint(&custom_name),
395                                        custom_object_features(),
396                                        app_label,
397                                        model,
398                                    );
399                                    let id = existing.id;
400                                    custom_types_by_name.insert(custom_name.clone(), existing);
401                                    id
402                                } else {
403                                    return Err(err.into());
404                                }
405                            } else {
406                                return Err(err.into());
407                            }
408                        }
409                    }
410                };
411                custom_type_ids.insert(type_name.as_str().to_string(), type_id);
412            }
413
414            for (type_name, type_schema) in custom_schema_types {
415                let Some(type_id) = custom_type_ids.get(type_name.as_str()).copied() else {
416                    return Err(anyhow!("custom object type id missing for {}", type_name));
417                };
418                let existing_fields = custom_fields_by_type_id.entry(type_id).or_default();
419                let mut provisioner = CustomObjectFieldProvisioner {
420                    adapter: self,
421                    registry: &registry,
422                    custom_object_type_id: type_id,
423                    existing_fields,
424                    created_object_fields: &mut created_object_fields,
425                    type_name: &type_name,
426                };
427                let mut desired_fields = BTreeSet::new();
428                for (field_name, field_schema) in &type_schema.key {
429                    if desired_fields.insert(field_name.clone()) {
430                        provisioner.ensure(field_name, field_schema, true).await?;
431                    }
432                }
433                for (field_name, field_schema) in &type_schema.fields {
434                    if desired_fields.insert(field_name.clone()) {
435                        provisioner.ensure(field_name, field_schema, false).await?;
436                    }
437                }
438                desired_fields_by_type_id.insert(type_id, desired_fields);
439            }
440        }
441
442        if custom_objects_available {
443            let resource_fields: Resource<Value> = self
444                .client
445                .resource("plugins/custom-objects/custom-object-type-fields/");
446            let resource_types: Resource<Value> = self
447                .client
448                .resource("plugins/custom-objects/custom-object-types/");
449
450            for custom_type in custom_types_by_name.values() {
451                let Some(type_name) = alembic_custom_object_name(custom_type) else {
452                    continue;
453                };
454                let is_desired = custom_schema_type_names.contains(type_name.as_str());
455                if is_desired {
456                    let Some(existing_fields) = custom_fields_by_type_id.get(&custom_type.id)
457                    else {
458                        continue;
459                    };
460                    let desired_fields = desired_fields_by_type_id.get(&custom_type.id);
461                    for (field_name, field_id) in existing_fields {
462                        if is_reserved_custom_object_field(field_name) {
463                            continue;
464                        }
465                        if desired_fields.is_some_and(|fields| fields.contains(field_name)) {
466                            continue;
467                        }
468                        match resource_fields.delete(*field_id).await {
469                            Ok(_) => {}
470                            Err(err) if is_404_error(&err) => {
471                                tracing::warn!(
472                                    type_name = %type_name,
473                                    field = %field_name,
474                                    "custom object field already deleted"
475                                );
476                            }
477                            Err(err) => return Err(err.into()),
478                        }
479                        deleted_object_fields.push(format!("{}.{}", type_name, field_name));
480                    }
481                } else {
482                    if let Some(existing_fields) = custom_fields_by_type_id.get(&custom_type.id) {
483                        for (field_name, field_id) in existing_fields {
484                            if is_reserved_custom_object_field(field_name) {
485                                continue;
486                            }
487                            match resource_fields.delete(*field_id).await {
488                                Ok(_) => {}
489                                Err(err) if is_404_error(&err) => {
490                                    tracing::warn!(
491                                        type_name = %type_name,
492                                        field = %field_name,
493                                        "custom object field already deleted"
494                                    );
495                                }
496                                Err(err) => return Err(err.into()),
497                            }
498                            deleted_object_fields.push(format!("{}.{}", type_name, field_name));
499                        }
500                    }
501                    match resource_types.delete(custom_type.id).await {
502                        Ok(_) => {}
503                        Err(err) if is_404_error(&err) => {
504                            tracing::warn!(
505                                type_name = %type_name,
506                                "custom object type already deleted"
507                            );
508                        }
509                        Err(err) => return Err(err.into()),
510                    }
511                    deleted_object_types.push(type_name);
512                }
513            }
514        }
515
516        Ok(ProvisionReport {
517            created_fields,
518            created_tags,
519            created_object_types,
520            created_object_fields,
521            deprecated_object_types: Vec::new(),
522            deprecated_object_fields: Vec::new(),
523            deleted_object_types,
524            deleted_object_fields,
525        })
526    }
527}
528
529impl NetBoxAdapter {
530    async fn apply_create(
531        &self,
532        op: &Op,
533        resolved: &mut BTreeMap<Uid, u64>,
534        registry: &ObjectTypeRegistry,
535        schema: &Schema,
536        custom_fields_by_type: &BTreeMap<String, BTreeSet<String>>,
537    ) -> Result<u64> {
538        let (uid, type_name, desired) = match op {
539            Op::Create {
540                uid,
541                type_name,
542                desired,
543            } => (*uid, type_name, desired),
544            _ => return Err(anyhow!("expected create operation")),
545        };
546        let info = registry
547            .info_for(type_name)
548            .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
549        let type_schema = schema
550            .types
551            .get(type_name.as_str())
552            .ok_or_else(|| anyhow!("missing schema for {}", type_name))?;
553        let resource: Resource<Value> = self.client.resource(info.endpoint.clone());
554        let custom_fields = custom_fields_by_type
555            .get(info.type_name.as_str())
556            .cloned()
557            .unwrap_or_default();
558        let body = build_request_body(
559            type_name,
560            type_schema,
561            &desired.attrs,
562            resolved,
563            &custom_fields,
564            &info.features,
565        )?;
566        let response: Value = match resource.create(&body).await {
567            Ok(response) => response,
568            Err(err) if is_conflict_error(&err) => {
569                if let Ok(existing) = self
570                    .lookup_backend_id(type_name, &info, type_schema, &desired.key, resolved)
571                    .await
572                {
573                    tracing::warn!(
574                        type_name = %type_name,
575                        key = %key_string(&desired.key),
576                        "create already exists; using existing object"
577                    );
578                    resolved.insert(uid, existing);
579                    return Ok(existing);
580                }
581                return Err(err.into());
582            }
583            Err(err) => return Err(err.into()),
584        };
585        let backend_id = response
586            .get("id")
587            .and_then(Value::as_u64)
588            .ok_or_else(|| anyhow!("create {} returned no id", type_name))?;
589        resolved.insert(uid, backend_id);
590        Ok(backend_id)
591    }
592
593    async fn apply_update(
594        &self,
595        op: &Op,
596        resolved: &BTreeMap<Uid, u64>,
597        registry: &ObjectTypeRegistry,
598        schema: &Schema,
599        custom_fields_by_type: &BTreeMap<String, BTreeSet<String>>,
600    ) -> Result<u64> {
601        let (uid, type_name, desired, backend_id) = match op {
602            Op::Update {
603                uid,
604                type_name,
605                desired,
606                backend_id,
607                ..
608            } => {
609                let id = match backend_id {
610                    Some(BackendId::Int(id)) => Some(*id),
611                    Some(_) => return Err(anyhow!("netbox requires integer backend id")),
612                    None => None,
613                };
614                (*uid, type_name, desired, id)
615            }
616            _ => return Err(anyhow!("expected update operation")),
617        };
618        let info = registry
619            .info_for(type_name)
620            .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
621        let type_schema = schema
622            .types
623            .get(type_name.as_str())
624            .ok_or_else(|| anyhow!("missing schema for {}", type_name))?;
625        let id = if let Some(id) = backend_id {
626            id
627        } else if let Some(id) = resolved.get(&uid).copied() {
628            id
629        } else {
630            self.lookup_backend_id(type_name, &info, type_schema, &desired.key, resolved)
631                .await
632                .with_context(|| format!("resolve backend id for {}", type_name))?
633        };
634        let resource: Resource<Value> = self.client.resource(info.endpoint.clone());
635        let custom_fields = custom_fields_by_type
636            .get(info.type_name.as_str())
637            .cloned()
638            .unwrap_or_default();
639        let body = build_request_body(
640            type_name,
641            type_schema,
642            &desired.attrs,
643            resolved,
644            &custom_fields,
645            &info.features,
646        )?;
647        let _response = resource.patch(id, &body).await?;
648        Ok(id)
649    }
650
651    async fn lookup_backend_id(
652        &self,
653        type_name: &TypeName,
654        info: &super::registry::ObjectTypeInfo,
655        type_schema: &TypeSchema,
656        key: &Key,
657        resolved: &BTreeMap<Uid, u64>,
658    ) -> Result<u64> {
659        let query = query_from_key(type_schema, key, resolved)?;
660        let resource: Resource<Value> = self.client.resource(info.endpoint.clone());
661        let page = resource.list(Some(query)).await?;
662        let item = page
663            .results
664            .into_iter()
665            .next()
666            .ok_or_else(|| anyhow!("{} not found for key {}", type_name, key_string(key)))?;
667        item.get("id")
668            .and_then(Value::as_u64)
669            .ok_or_else(|| anyhow!("{} lookup missing id", type_name))
670    }
671
672    async fn create_tags(&self, tags: &[String]) -> Result<()> {
673        let resource = self.client.extras().tags();
674        for tag in tags {
675            let payload = serde_json::json!({
676                "name": tag,
677                "slug": slugify(tag),
678            });
679            if let Err(err) = resource.create(&payload).await {
680                let existing = self.client.fetch_tags().await?;
681                if existing.contains(tag) {
682                    tracing::warn!(tag = %tag, "tag already exists");
683                    continue;
684                }
685                return Err(err.into());
686            }
687        }
688        Ok(())
689    }
690
691    async fn create_custom_field(
692        &self,
693        type_name: &TypeName,
694        field_name: &str,
695        field_schema: &FieldSchema,
696    ) -> Result<bool> {
697        let field_type = custom_field_type_for_schema(field_schema);
698        let mut payload = Map::new();
699        payload.insert("name".to_string(), Value::String(field_name.to_string()));
700        payload.insert("label".to_string(), Value::String(field_name.to_string()));
701        payload.insert("type".to_string(), Value::String(field_type));
702        payload.insert(
703            "object_types".to_string(),
704            Value::Array(vec![Value::String(type_name.as_str().to_string())]),
705        );
706        if field_schema.required {
707            payload.insert("required".to_string(), Value::Bool(true));
708        }
709        if let Some(description) = &field_schema.description {
710            payload.insert(
711                "description".to_string(),
712                Value::String(description.clone()),
713            );
714        }
715        let resource = self.client.extras().custom_fields();
716        match resource.create(&Value::Object(payload)).await {
717            Ok(_) => Ok(true),
718            Err(err) => {
719                let existing = self.client.fetch_custom_fields().await?;
720                if existing
721                    .get(type_name.as_str())
722                    .is_some_and(|fields| fields.contains(field_name))
723                {
724                    tracing::warn!(
725                        type_name = %type_name,
726                        field = %field_name,
727                        "custom field already exists"
728                    );
729                    Ok(false)
730                } else {
731                    Err(err.into())
732                }
733            }
734        }
735    }
736}
737
738fn extract_attrs(value: Value) -> Result<(u64, JsonMap)> {
739    let Value::Object(mut map) = value else {
740        return Err(anyhow!("expected object payload"));
741    };
742    let backend_id = map
743        .get("id")
744        .and_then(Value::as_u64)
745        .ok_or_else(|| anyhow!("missing id in payload"))?;
746    let custom_fields = map.remove("custom_fields");
747    let tags = map.remove("tags");
748    map.remove("id");
749    map.remove("url");
750    map.remove("display");
751    map.remove("custom_object_type");
752    let mut attrs: JsonMap = map.into_iter().collect::<BTreeMap<_, _>>().into();
753    if let Some(Value::Object(fields)) = custom_fields {
754        for (key, value) in fields {
755            attrs.entry(key).or_insert(value);
756        }
757    }
758    if let Some(tags_value) = tags {
759        let tags = tags_from_value(&tags_value)?;
760        attrs.insert(
761            "tags".to_string(),
762            Value::Array(tags.into_iter().map(Value::String).collect()),
763        );
764    }
765    Ok((backend_id, attrs))
766}
767
768fn normalize_attrs(
769    attrs: &mut JsonMap,
770    type_schema: &TypeSchema,
771    schema: &Schema,
772    registry: &ObjectTypeRegistry,
773    mappings: &super::state::StateMappings,
774) {
775    let keys: Vec<String> = attrs.keys().cloned().collect();
776    for key in keys {
777        if let Some(value) = attrs.get(&key).cloned() {
778            // Look up the field's target type from the schema
779            let target_hint = type_schema
780                .fields
781                .get(&key)
782                .map(|fs| &fs.r#type)
783                .and_then(|ft| match ft {
784                    FieldType::Ref { target } => Some(target.as_str()),
785                    FieldType::ListRef { target } => Some(target.as_str()),
786                    _ => None,
787                });
788            let normalized = normalize_value(value, target_hint, schema, registry, mappings);
789            attrs.insert(key, normalized);
790        }
791    }
792    if attrs.contains_key("type") && !attrs.contains_key("if_type") {
793        if let Some(value) = attrs.remove("type") {
794            attrs.insert("if_type".to_string(), value);
795        }
796    }
797    if let (Some(Value::String(kind)), Some(id_value)) = (
798        attrs.remove("assigned_object_type"),
799        attrs.remove("assigned_object_id"),
800    ) {
801        if kind == "dcim.interface" {
802            if let Some(id) = as_u64(&id_value) {
803                if let Some(uid) = mappings.uid_for("dcim.interface", id) {
804                    attrs.insert(
805                        "assigned_interface".to_string(),
806                        Value::String(uid.to_string()),
807                    );
808                }
809            }
810        }
811    }
812    if let (Some(Value::String(scope)), Some(id_value)) =
813        (attrs.remove("scope_type"), attrs.remove("scope_id"))
814    {
815        if scope == "dcim.site" {
816            if let Some(id) = as_u64(&id_value) {
817                if let Some(uid) = mappings.uid_for("dcim.site", id) {
818                    attrs.insert("site".to_string(), Value::String(uid.to_string()));
819                }
820            }
821        }
822    }
823}
824
825fn normalize_value(
826    value: Value,
827    target_hint: Option<&str>,
828    schema: &Schema,
829    registry: &ObjectTypeRegistry,
830    mappings: &super::state::StateMappings,
831) -> Value {
832    match value {
833        Value::Array(items) => Value::Array(
834            items
835                .into_iter()
836                .map(|item| normalize_value(item, target_hint, schema, registry, mappings))
837                .collect(),
838        ),
839        Value::Object(map) => {
840            if let Some(id) = map.get("id").and_then(as_u64) {
841                // First, try existing approach: lookup via URL + mappings
842                if let Some(uid) = uid_for_nested_object(&map, registry, mappings) {
843                    return Value::String(uid.to_string());
844                }
845                // If we know the target type from schema, try to generate UID from key fields
846                if let Some(target) = target_hint {
847                    if let Some(uid) = uid_from_key_fields(&map, target, schema, registry, mappings)
848                    {
849                        return Value::String(uid.to_string());
850                    }
851                }
852                // If it looks like a resource summary but isn't managed by us,
853                // fall back to the ID integer to match desired state integers.
854                if map.contains_key("url") || map.contains_key("display") {
855                    return Value::Number(id.into());
856                }
857            }
858            if let Some(value) = map.get("value").and_then(Value::as_str) {
859                let label_only = map.keys().all(|key| key == "value" || key == "label");
860                if label_only {
861                    return Value::String(value.to_string());
862                }
863            }
864            // Recurse into nested objects without a target hint
865            let mut normalized = Map::new();
866            for (key, value) in map {
867                normalized.insert(
868                    key,
869                    normalize_value(value, None, schema, registry, mappings),
870                );
871            }
872            Value::Object(normalized)
873        }
874        other => other,
875    }
876}
877
878fn as_u64(value: &Value) -> Option<u64> {
879    match value {
880        Value::Number(num) => num.as_u64(),
881        Value::String(raw) => raw.parse().ok(),
882        _ => None,
883    }
884}
885
886fn uid_for_nested_object(
887    map: &Map<String, Value>,
888    registry: &ObjectTypeRegistry,
889    mappings: &super::state::StateMappings,
890) -> Option<Uid> {
891    let id = map.get("id")?.as_u64()?;
892    let endpoint = map
893        .get("url")
894        .and_then(Value::as_str)
895        .and_then(|url| registry.type_name_for_endpoint(url))?;
896    mappings.uid_for(endpoint, id)
897}
898
899/// Generate a UID from key fields when we know the target type but the object isn't in mappings.
900/// This handles the case where nested objects don't have URLs but we know the target type from schema.
901fn uid_from_key_fields(
902    map: &Map<String, Value>,
903    target: &str,
904    schema: &Schema,
905    registry: &ObjectTypeRegistry,
906    mappings: &super::state::StateMappings,
907) -> Option<Uid> {
908    // First, try to determine type from URL if available and use mappings
909    if let Some(type_from_url) = map
910        .get("url")
911        .and_then(Value::as_str)
912        .and_then(|url| registry.type_name_for_endpoint(url))
913    {
914        if let Some(id) = map.get("id").and_then(as_u64) {
915            if let Some(uid) = mappings.uid_for(type_from_url, id) {
916                return Some(uid);
917            }
918        }
919    }
920
921    // Get the target type's schema to find its key fields
922    let target_schema = schema.types.get(target)?;
923
924    // Build a key from available fields
925    let mut key_map = BTreeMap::new();
926    for key_field in target_schema.key.keys() {
927        let value = map.get(key_field)?;
928        key_map.insert(key_field.clone(), value.clone());
929    }
930
931    // Generate deterministic UID from type name and key
932    let key = Key::from(key_map);
933    Some(uid_v5(target, &key_string(&key)))
934}
935
936fn build_request_body(
937    type_name: &TypeName,
938    type_schema: &TypeSchema,
939    attrs: &JsonMap,
940    resolved: &BTreeMap<Uid, u64>,
941    custom_fields: &BTreeSet<String>,
942    features: &BTreeSet<String>,
943) -> Result<Value> {
944    let mut body = Map::new();
945    let mut custom = Map::new();
946
947    for (key, value) in attrs.iter() {
948        let api_key = if type_name.as_str() == "dcim.interface" && key == "if_type" {
949            "type"
950        } else {
951            key.as_str()
952        };
953        if key == "tags" {
954            if !supports_feature(features, &["tags"]) {
955                return Err(anyhow!("{} does not support tags", type_name));
956            }
957            let tags = tags_from_value(value)?;
958            let tag_inputs = build_tag_inputs(&tags);
959            body.insert(api_key.to_string(), serde_json::to_value(tag_inputs)?);
960            continue;
961        }
962
963        let field_schema = type_schema
964            .fields
965            .get(key)
966            .ok_or_else(|| anyhow!("missing schema for field {key}"))?;
967        let encoded = resolve_value_for_type(&field_schema.r#type, value.clone(), resolved)?;
968
969        if custom_fields.contains(key) {
970            if !supports_feature(features, &["custom-fields"]) {
971                return Err(anyhow!("{} does not support custom fields", type_name));
972            }
973            custom.insert(key.clone(), encoded);
974        } else {
975            body.insert(api_key.to_string(), encoded);
976        }
977    }
978
979    if !custom.is_empty() {
980        body.insert("custom_fields".to_string(), Value::Object(custom));
981    }
982
983    Ok(Value::Object(body))
984}
985
986fn resolve_value_for_type(
987    field_type: &alembic_core::FieldType,
988    value: Value,
989    resolved: &BTreeMap<Uid, u64>,
990) -> Result<Value> {
991    alembic_engine::resolve_value_for_type(field_type, value, resolved, |id| {
992        Value::Number((*id).into())
993    })
994}
995
996fn query_from_key(
997    type_schema: &TypeSchema,
998    key: &Key,
999    resolved: &BTreeMap<Uid, u64>,
1000) -> Result<QueryBuilder> {
1001    let mut query = QueryBuilder::new();
1002    for (field, value) in query_filters_from_key(type_schema, key, resolved)? {
1003        query = query.filter(field, value);
1004    }
1005    Ok(query)
1006}
1007
1008fn collect_tag_names(ops: &[Op], registry: &ObjectTypeRegistry) -> Result<BTreeSet<String>> {
1009    let mut tags = BTreeSet::new();
1010    for op in ops {
1011        let (type_name, desired) = match op {
1012            Op::Create {
1013                type_name, desired, ..
1014            } => (type_name, desired),
1015            Op::Update {
1016                type_name, desired, ..
1017            } => (type_name, desired),
1018            Op::Delete { .. } => continue,
1019        };
1020        if let Some(tag_value) = desired.attrs.get("tags") {
1021            let info = registry
1022                .info_for(type_name)
1023                .ok_or_else(|| anyhow!("unsupported type {}", type_name))?;
1024            if !supports_feature(&info.features, &["tags"]) {
1025                return Err(anyhow!("{} does not support tags", type_name));
1026            }
1027            for tag in tags_from_value(tag_value)? {
1028                tags.insert(tag);
1029            }
1030        }
1031    }
1032    Ok(tags)
1033}
1034
1035async fn build_registry_for_schema(
1036    adapter: &NetBoxAdapter,
1037    schema: &Schema,
1038) -> Result<ObjectTypeRegistry> {
1039    let mut registry = adapter.client.fetch_object_types().await?;
1040    let mut missing = Vec::new();
1041    for type_name in schema.types.keys() {
1042        let type_name = TypeName::new(type_name);
1043        if !registry.contains_type(&type_name) {
1044            missing.push(type_name);
1045        }
1046    }
1047    if missing.is_empty() {
1048        return Ok(registry);
1049    }
1050
1051    let custom_object_types = adapter.client.fetch_custom_object_types().await?;
1052    if custom_object_types.is_none() {
1053        let list = missing
1054            .iter()
1055            .map(|t| t.as_str())
1056            .collect::<Vec<_>>()
1057            .join(", ");
1058        return Err(anyhow!(
1059            "schema includes custom types but netbox custom objects are not available: {list}"
1060        ));
1061    }
1062    let custom_object_types = custom_object_types.unwrap_or_default();
1063    let mut custom_by_name: BTreeMap<String, CustomObjectType> = BTreeMap::new();
1064    for custom_type in custom_object_types {
1065        custom_by_name.insert(custom_type.name.clone(), custom_type);
1066    }
1067
1068    for type_name in missing {
1069        let custom_name = custom_object_type_name(&type_name);
1070        let endpoint = custom_object_endpoint(&custom_name);
1071        if let Some(custom_type) = custom_by_name.get(&custom_name) {
1072            if let Some((app_label, model)) = custom_object_type_parts(custom_type) {
1073                registry.insert_custom_object_type(
1074                    type_name,
1075                    endpoint,
1076                    custom_object_features(),
1077                    app_label,
1078                    model,
1079                );
1080                continue;
1081            }
1082        }
1083        registry.insert_custom_object_type(
1084            type_name,
1085            endpoint,
1086            custom_object_features(),
1087            CUSTOM_OBJECT_APP_LABEL.to_string(),
1088            custom_name,
1089        );
1090    }
1091
1092    Ok(registry)
1093}
1094
1095struct CustomObjectFieldProvisioner<'a> {
1096    adapter: &'a NetBoxAdapter,
1097    registry: &'a ObjectTypeRegistry,
1098    custom_object_type_id: u64,
1099    existing_fields: &'a mut BTreeMap<String, u64>,
1100    created_object_fields: &'a mut Vec<String>,
1101    type_name: &'a TypeName,
1102}
1103
1104impl<'a> CustomObjectFieldProvisioner<'a> {
1105    async fn ensure(
1106        &mut self,
1107        field_name: &str,
1108        field_schema: &FieldSchema,
1109        is_key: bool,
1110    ) -> Result<()> {
1111        if is_reserved_custom_object_field(field_name) {
1112            return Ok(());
1113        }
1114        if self.existing_fields.contains_key(field_name) {
1115            return Ok(());
1116        }
1117        validate_custom_object_field_name(field_name)?;
1118        let payload = custom_object_field_payload(
1119            self.registry,
1120            self.custom_object_type_id,
1121            field_name,
1122            field_schema,
1123            is_key,
1124        )?;
1125        let resource: Resource<Value> = self
1126            .adapter
1127            .client
1128            .resource("plugins/custom-objects/custom-object-type-fields/");
1129        match resource.create(&payload).await {
1130            Ok(created) => {
1131                if let Some(field_id) = custom_object_field_id(&created) {
1132                    self.existing_fields
1133                        .insert(field_name.to_string(), field_id);
1134                }
1135                self.created_object_fields
1136                    .push(format!("{}.{}", self.type_name, field_name));
1137            }
1138            Err(err) => {
1139                let Some(fields) = self
1140                    .adapter
1141                    .client
1142                    .fetch_custom_object_type_fields()
1143                    .await?
1144                else {
1145                    return Err(err.into());
1146                };
1147                if fields.iter().any(|field| {
1148                    field.custom_object_type == self.custom_object_type_id
1149                        && field.name == field_name
1150                }) {
1151                    tracing::warn!(
1152                        type_name = %self.type_name,
1153                        field = %field_name,
1154                        "custom object field already exists"
1155                    );
1156                    if let Some(existing) = fields.iter().find(|field| {
1157                        field.custom_object_type == self.custom_object_type_id
1158                            && field.name == field_name
1159                    }) {
1160                        self.existing_fields
1161                            .insert(field_name.to_string(), existing.id);
1162                    }
1163                } else {
1164                    return Err(err.into());
1165                }
1166            }
1167        }
1168        Ok(())
1169    }
1170}
1171
1172async fn native_fields_for_type(
1173    adapter: &NetBoxAdapter,
1174    info: &super::registry::ObjectTypeInfo,
1175    type_schema: &TypeSchema,
1176) -> Result<BTreeSet<String>> {
1177    let mut native: BTreeSet<String> = type_schema.key.keys().cloned().collect();
1178    for field in [
1179        "name",
1180        "slug",
1181        "description",
1182        "status",
1183        "role",
1184        "type",
1185        "site",
1186        "tenant",
1187        "device",
1188        "tags",
1189        "custom_fields",
1190        "local_context_data",
1191        "created",
1192        "last_updated",
1193    ] {
1194        native.insert(field.to_string());
1195    }
1196
1197    let resource: Resource<Value> = adapter.client.resource(info.endpoint.clone());
1198    let page = resource
1199        .list(Some(QueryBuilder::default().limit(1)))
1200        .await?;
1201    if let Some(Value::Object(map)) = page.results.into_iter().next() {
1202        for key in map.keys() {
1203            native.insert(key.clone());
1204        }
1205    }
1206    if info.type_name.as_str() == "dcim.interface" {
1207        native.insert("if_type".to_string());
1208    }
1209
1210    Ok(native)
1211}
1212
1213fn custom_object_type_name(type_name: &TypeName) -> String {
1214    let mut out = String::new();
1215    let mut last_underscore = false;
1216    for ch in type_name.as_str().chars() {
1217        let lower = ch.to_ascii_lowercase();
1218        if lower.is_ascii_alphanumeric() {
1219            out.push(lower);
1220            last_underscore = false;
1221        } else if !last_underscore {
1222            out.push('_');
1223            last_underscore = true;
1224        }
1225    }
1226    while out.ends_with('_') {
1227        out.pop();
1228    }
1229    out
1230}
1231
1232fn custom_object_features() -> BTreeSet<String> {
1233    [CUSTOM_OBJECT_FEATURE.to_string(), "tags".to_string()]
1234        .into_iter()
1235        .collect()
1236}
1237
1238fn custom_object_type_parts(custom_type: &CustomObjectType) -> Option<(String, String)> {
1239    if let Some(parts) = custom_type.object_type_parts() {
1240        return Some(parts);
1241    }
1242    custom_type
1243        .table_model_name
1244        .as_deref()
1245        .map(|name| (CUSTOM_OBJECT_APP_LABEL.to_string(), name.to_lowercase()))
1246}
1247
1248fn alembic_custom_object_name(custom_type: &CustomObjectType) -> Option<String> {
1249    custom_type
1250        .description
1251        .as_deref()
1252        .and_then(|desc| desc.strip_prefix(ALEMBIC_CUSTOM_OBJECT_PREFIX))
1253        .map(|name| name.to_string())
1254}
1255
1256fn custom_object_endpoint(custom_name: &str) -> String {
1257    format!("plugins/custom-objects/{custom_name}/")
1258}
1259
1260fn custom_object_verbose_name_plural(type_name: &TypeName) -> String {
1261    let base = type_name
1262        .as_str()
1263        .split('.')
1264        .next_back()
1265        .unwrap_or_else(|| type_name.as_str());
1266    let label = title_case(base);
1267    if label.ends_with('s') {
1268        label
1269    } else {
1270        format!("{label}s")
1271    }
1272}
1273
1274fn custom_object_field_id(value: &Value) -> Option<u64> {
1275    match value {
1276        Value::Object(map) => map.get("id").and_then(as_u64),
1277        _ => None,
1278    }
1279}
1280
1281fn title_case(value: &str) -> String {
1282    value
1283        .split(|ch: char| !ch.is_ascii_alphanumeric())
1284        .filter(|segment| !segment.is_empty())
1285        .map(|segment| {
1286            let mut chars = segment.chars();
1287            let Some(first) = chars.next() else {
1288                return String::new();
1289            };
1290            let mut out = String::new();
1291            out.push(first.to_ascii_uppercase());
1292            out.push_str(&chars.as_str().to_ascii_lowercase());
1293            out
1294        })
1295        .collect::<Vec<_>>()
1296        .join(" ")
1297}
1298
1299fn is_reserved_custom_object_field(name: &str) -> bool {
1300    matches!(
1301        name,
1302        "id" | "url" | "display" | "custom_object_type" | "created" | "last_updated" | "tags"
1303    )
1304}
1305
1306fn validate_custom_object_field_name(name: &str) -> Result<()> {
1307    if name.is_empty()
1308        || !name
1309            .chars()
1310            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1311    {
1312        return Err(anyhow!(
1313            "invalid custom object field name '{}': only letters, digits, and underscores are allowed",
1314            name
1315        ));
1316    }
1317    Ok(())
1318}
1319
1320fn custom_object_field_payload(
1321    registry: &ObjectTypeRegistry,
1322    custom_object_type_id: u64,
1323    field_name: &str,
1324    field_schema: &FieldSchema,
1325    is_key: bool,
1326) -> Result<Value> {
1327    let mut payload = Map::new();
1328    payload.insert(
1329        "custom_object_type".to_string(),
1330        Value::Number(custom_object_type_id.into()),
1331    );
1332    payload.insert("name".to_string(), Value::String(field_name.to_string()));
1333    payload.insert("label".to_string(), Value::String(title_case(field_name)));
1334    payload.insert(
1335        "type".to_string(),
1336        Value::String(custom_object_field_type(&field_schema.r#type).to_string()),
1337    );
1338    if is_key || field_schema.required {
1339        payload.insert("required".to_string(), Value::Bool(true));
1340    }
1341    if let Some(pattern) = &field_schema.pattern {
1342        payload.insert(
1343            "validation_regex".to_string(),
1344            Value::String(pattern.clone()),
1345        );
1346    }
1347
1348    match &field_schema.r#type {
1349        FieldType::Ref { target } | FieldType::ListRef { target } => {
1350            let target_type = TypeName::new(target);
1351            if registry.contains_type(&target_type) {
1352                let info = registry
1353                    .info_for(&target_type)
1354                    .ok_or_else(|| anyhow!("invalid target type {}", target))?;
1355                payload.insert("app_label".to_string(), Value::String(info.app_label));
1356                payload.insert("model".to_string(), Value::String(info.model));
1357            } else {
1358                let custom_name = custom_object_type_name(&target_type);
1359                payload.insert(
1360                    "app_label".to_string(),
1361                    Value::String(CUSTOM_OBJECT_APP_LABEL.to_string()),
1362                );
1363                payload.insert("model".to_string(), Value::String(custom_name));
1364            }
1365        }
1366        _ => {}
1367    }
1368
1369    Ok(Value::Object(payload))
1370}
1371
1372fn custom_object_field_type(field_type: &FieldType) -> &'static str {
1373    match field_type {
1374        FieldType::Text => "longtext",
1375        FieldType::Int => "integer",
1376        FieldType::Float => "decimal",
1377        FieldType::Bool => "boolean",
1378        FieldType::Date => "date",
1379        FieldType::Datetime => "datetime",
1380        FieldType::Json | FieldType::List { .. } | FieldType::Map { .. } => "json",
1381        FieldType::Ref { .. } => "object",
1382        FieldType::ListRef { .. } => "multiobject",
1383        _ => "text",
1384    }
1385}
1386
1387fn supports_feature(features: &BTreeSet<String>, candidates: &[&str]) -> bool {
1388    candidates.iter().any(|name| features.contains(*name))
1389}
1390
1391fn is_missing_ref_error(err: &anyhow::Error) -> bool {
1392    err.downcast_ref::<AdapterApplyError>()
1393        .is_some_and(|e| matches!(e, AdapterApplyError::MissingRef { .. }))
1394}
1395
1396fn is_404_error(err: &netbox::Error) -> bool {
1397    err.to_string().contains("status 404")
1398}
1399
1400fn is_404_anyhow(err: &anyhow::Error) -> bool {
1401    err.downcast_ref::<netbox::Error>()
1402        .is_some_and(|e| matches!(e, netbox::Error::ApiError { status: 404, .. }))
1403}
1404
1405fn is_conflict_error(err: &netbox::Error) -> bool {
1406    match err {
1407        netbox::Error::ApiError {
1408            status,
1409            message,
1410            body,
1411        } => {
1412            if !matches!(status, 400 | 409) {
1413                return false;
1414            }
1415            let message = message.to_lowercase();
1416            let body = body.to_lowercase();
1417            message.contains("already exists")
1418                || message.contains("unique")
1419                || body.contains("already exists")
1420                || body.contains("unique")
1421        }
1422        _ => false,
1423    }
1424}
1425
1426fn describe_missing_refs(ops: &[Op], resolved: &BTreeMap<Uid, u64>) -> String {
1427    let mut missing = BTreeSet::new();
1428    for op in ops {
1429        if let Op::Create { desired, .. } | Op::Update { desired, .. } = op {
1430            for value in desired.attrs.values() {
1431                collect_missing_refs(value, resolved, &mut missing);
1432            }
1433        }
1434    }
1435    missing
1436        .into_iter()
1437        .map(|uid| uid.to_string())
1438        .collect::<Vec<_>>()
1439        .join(", ")
1440}
1441
1442fn collect_missing_refs(value: &Value, resolved: &BTreeMap<Uid, u64>, missing: &mut BTreeSet<Uid>) {
1443    match value {
1444        Value::String(raw) => {
1445            if let Ok(uid) = Uid::parse_str(raw) {
1446                if !resolved.contains_key(&uid) {
1447                    missing.insert(uid);
1448                }
1449            }
1450        }
1451        Value::Array(items) => {
1452            for item in items {
1453                collect_missing_refs(item, resolved, missing);
1454            }
1455        }
1456        Value::Object(map) => {
1457            for value in map.values() {
1458                collect_missing_refs(value, resolved, missing);
1459            }
1460        }
1461        _ => {}
1462    }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::is_conflict_error;
1468
1469    #[test]
1470    fn conflict_error_detects_unique_message() {
1471        let err = netbox::Error::ApiError {
1472            status: 400,
1473            message: "slug: This field must be unique.".to_string(),
1474            body: String::new(),
1475        };
1476        assert!(is_conflict_error(&err));
1477    }
1478
1479    #[test]
1480    fn conflict_error_rejects_other_status() {
1481        let err = netbox::Error::ApiError {
1482            status: 404,
1483            message: "Not found".to_string(),
1484            body: String::new(),
1485        };
1486        assert!(!is_conflict_error(&err));
1487    }
1488}
1489
1490#[cfg(test)]
1491mod test_normalization {
1492    use super::*;
1493    use alembic_core::FieldSchema;
1494    use serde_json::json;
1495
1496    #[test]
1497    fn test_normalize_value_netbox() {
1498        let registry = ObjectTypeRegistry::default();
1499        let mappings = super::super::state::StateMappings::default();
1500        let schema = Schema {
1501            types: BTreeMap::new(),
1502        };
1503
1504        // Test summary object to integer ID normalization
1505        let summary = json!({
1506            "id": 5,
1507            "url": "http://localhost/api/dcim/sites/5/",
1508            "display": "FRA1"
1509        });
1510        let normalized = normalize_value(summary, None, &schema, &registry, &mappings);
1511        assert_eq!(normalized, json!(5));
1512
1513        // Test value/label normalization
1514        let status = json!({
1515            "value": "active",
1516            "label": "Active"
1517        });
1518        let normalized = normalize_value(status, None, &schema, &registry, &mappings);
1519        assert_eq!(normalized, json!("active"));
1520    }
1521
1522    #[test]
1523    fn test_normalize_attrs_netbox() {
1524        let registry = ObjectTypeRegistry::default();
1525        let mappings = super::super::state::StateMappings::default();
1526        let type_schema = TypeSchema {
1527            key: BTreeMap::new(),
1528            fields: BTreeMap::new(),
1529        };
1530        let schema = Schema {
1531            types: BTreeMap::new(),
1532        };
1533        let mut attrs = JsonMap::default();
1534        attrs.insert("type".to_string(), json!("1000base-t"));
1535
1536        normalize_attrs(&mut attrs, &type_schema, &schema, &registry, &mappings);
1537        assert_eq!(attrs.get("if_type").unwrap(), &json!("1000base-t"));
1538    }
1539
1540    #[test]
1541    fn test_uid_from_key_fields() {
1542        let registry = ObjectTypeRegistry::default();
1543        let mappings = super::super::state::StateMappings::default();
1544
1545        // Build a schema with a type that has "name" as the key field
1546        let mut schema = Schema {
1547            types: BTreeMap::new(),
1548        };
1549        let mut type_schema = TypeSchema {
1550            key: BTreeMap::new(),
1551            fields: BTreeMap::new(),
1552        };
1553        type_schema.key.insert(
1554            "name".to_string(),
1555            FieldSchema {
1556                r#type: FieldType::String,
1557                required: true,
1558                nullable: false,
1559                description: None,
1560                format: None,
1561                pattern: None,
1562            },
1563        );
1564        schema.types.insert("dcim.device".to_string(), type_schema);
1565
1566        // Nested object without URL but with key field
1567        let nested = serde_json::Map::from_iter([
1568            ("id".to_string(), json!(123)),
1569            ("name".to_string(), json!("router-01")),
1570        ]);
1571
1572        let uid = uid_from_key_fields(&nested, "dcim.device", &schema, &registry, &mappings);
1573        assert!(uid.is_some());
1574
1575        // The UID should be deterministic: same inputs = same output
1576        let uid2 = uid_from_key_fields(&nested, "dcim.device", &schema, &registry, &mappings);
1577        assert_eq!(uid, uid2);
1578
1579        // Different key value should produce different UID
1580        let nested2 = serde_json::Map::from_iter([
1581            ("id".to_string(), json!(456)),
1582            ("name".to_string(), json!("router-02")),
1583        ]);
1584        let uid3 = uid_from_key_fields(&nested2, "dcim.device", &schema, &registry, &mappings);
1585        assert!(uid3.is_some());
1586        assert_ne!(uid, uid3);
1587    }
1588
1589    #[test]
1590    fn test_build_request_body() {
1591        let mut fields = BTreeMap::new();
1592        fields.insert(
1593            "site".to_string(),
1594            FieldSchema {
1595                r#type: alembic_core::FieldType::Ref {
1596                    target: "dcim.site".to_string(),
1597                },
1598                required: true,
1599                nullable: false,
1600                description: None,
1601                format: None,
1602                pattern: None,
1603            },
1604        );
1605        let type_schema = TypeSchema {
1606            key: BTreeMap::new(),
1607            fields,
1608        };
1609        let mut attrs = JsonMap::default();
1610        let site_uid = Uid::from_u128(1);
1611        attrs.insert("site".to_string(), json!(site_uid.to_string()));
1612
1613        let mut resolved = BTreeMap::new();
1614        resolved.insert(site_uid, 5);
1615
1616        let body = build_request_body(
1617            &TypeName::new("dcim.device"),
1618            &type_schema,
1619            &attrs,
1620            &resolved,
1621            &BTreeSet::new(),
1622            &BTreeSet::new(),
1623        )
1624        .unwrap();
1625        assert_eq!(body.get("site").unwrap(), &json!(5));
1626    }
1627
1628    #[test]
1629    fn test_resolve_value_for_type() {
1630        let resolved = BTreeMap::from([(Uid::from_u128(1), 5u64)]);
1631
1632        // Ref
1633        let val = resolve_value_for_type(
1634            &alembic_core::FieldType::Ref {
1635                target: "t".to_string(),
1636            },
1637            json!(Uid::from_u128(1).to_string()),
1638            &resolved,
1639        )
1640        .unwrap();
1641        assert_eq!(val, json!(5));
1642
1643        // ListRef
1644        let val = resolve_value_for_type(
1645            &alembic_core::FieldType::ListRef {
1646                target: "t".to_string(),
1647            },
1648            json!([Uid::from_u128(1).to_string()]),
1649            &resolved,
1650        )
1651        .unwrap();
1652        assert_eq!(val, json!([5]));
1653    }
1654
1655    #[test]
1656    fn test_supports_feature() {
1657        let mut features = BTreeSet::new();
1658        features.insert("tags".to_string());
1659        assert!(supports_feature(&features, &["tags"]));
1660        assert!(!supports_feature(&features, &["custom-fields"]));
1661    }
1662
1663    #[test]
1664    fn test_query_from_key() {
1665        let mut key_fields = BTreeMap::new();
1666        key_fields.insert(
1667            "name".to_string(),
1668            FieldSchema {
1669                r#type: alembic_core::FieldType::String,
1670                required: true,
1671                nullable: false,
1672                description: None,
1673                format: None,
1674                pattern: None,
1675            },
1676        );
1677        key_fields.insert(
1678            "site".to_string(),
1679            FieldSchema {
1680                r#type: alembic_core::FieldType::Ref {
1681                    target: "dcim.site".to_string(),
1682                },
1683                required: true,
1684                nullable: false,
1685                description: None,
1686                format: None,
1687                pattern: None,
1688            },
1689        );
1690        let type_schema = TypeSchema {
1691            key: key_fields,
1692            fields: BTreeMap::new(),
1693        };
1694
1695        let site_uid = Uid::from_u128(1);
1696        let mut key_map = BTreeMap::new();
1697        key_map.insert("name".to_string(), json!("leaf01"));
1698        key_map.insert("site".to_string(), json!(site_uid.to_string()));
1699        let key = Key::from(key_map);
1700
1701        let mut resolved = BTreeMap::new();
1702        resolved.insert(site_uid, 5u64);
1703
1704        let query = query_from_key(&type_schema, &key, &resolved).unwrap();
1705        let json = serde_json::to_value(&query).unwrap();
1706        let pairs = json.as_array().unwrap();
1707        assert_eq!(pairs.len(), 2);
1708        assert!(pairs.iter().any(|p| p == &json!(["name", "leaf01"])));
1709        assert!(pairs.iter().any(|p| p == &json!(["site", "5"])));
1710    }
1711}