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, ®istry, &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, ®istry)?;
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: ®istry,
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: ®istry,
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 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 if let Some(uid) = uid_for_nested_object(&map, registry, mappings) {
843 return Value::String(uid.to_string());
844 }
845 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 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 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
899fn 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 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 let target_schema = schema.types.get(target)?;
923
924 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 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 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, ®istry, &mappings);
1511 assert_eq!(normalized, json!(5));
1512
1513 let status = json!({
1515 "value": "active",
1516 "label": "Active"
1517 });
1518 let normalized = normalize_value(status, None, &schema, ®istry, &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, ®istry, &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 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 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, ®istry, &mappings);
1573 assert!(uid.is_some());
1574
1575 let uid2 = uid_from_key_fields(&nested, "dcim.device", &schema, ®istry, &mappings);
1577 assert_eq!(uid, uid2);
1578
1579 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, ®istry, &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 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 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}