1use crate::case::to_camel_case;
7use crate::config::{
8 load_from_pool, resolve, IncludeDirection, KvStoreConfig, ResolvedEntity, ResolvedModel,
9};
10use crate::state::AppState;
11use crate::store::list_package_ids;
12use axum::extract::State;
13use axum::Json;
14use std::collections::HashMap;
15use utoipa::openapi::path::{
16 HttpMethod, Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn,
17 PathItemBuilder, PathsBuilder,
18};
19use utoipa::openapi::request_body::RequestBodyBuilder;
20use utoipa::openapi::response::{Response, ResponsesBuilder};
21use utoipa::openapi::schema::{
22 ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, SchemaType, Type,
23};
24use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
25use utoipa::openapi::{Content, Info, OpenApi, OpenApiBuilder, RefOr, Required};
26
27fn build_server() -> utoipa::openapi::server::Server {
29 ServerBuilder::new()
30 .url("http://{host}:{port}")
31 .parameter(
32 "host",
33 ServerVariableBuilder::new()
34 .default_value("localhost")
35 .description(Some("API host")),
36 )
37 .parameter(
38 "port",
39 ServerVariableBuilder::new()
40 .default_value("3000")
41 .description(Some("API port")),
42 )
43 .build()
44}
45
46fn json_object_schema() -> Schema {
47 Schema::Object(
48 ObjectBuilder::new()
49 .schema_type(SchemaType::new(Type::Object))
50 .description(Some(
51 "JSON object; keys may be in camelCase (e.g. entity fields).",
52 ))
53 .into(),
54 )
55}
56
57fn column_schema_from_pg_type(pg_type: Option<&str>) -> Schema {
59 let t = pg_type.unwrap_or("").to_lowercase();
60 if t.ends_with("[]") || t.starts_with('_') {
63 let element_type = t.trim_end_matches("[]").trim_start_matches('_');
64 let item_schema = column_schema_from_pg_type(Some(element_type));
65 return Schema::Array(
66 utoipa::openapi::schema::ArrayBuilder::new()
67 .items(RefOr::T(item_schema))
68 .build(),
69 );
70 }
71 if t.contains("int") || t.contains("serial") {
72 return Schema::Object(
73 utoipa::openapi::schema::ObjectBuilder::new()
74 .schema_type(SchemaType::new(Type::Integer))
75 .into(),
76 );
77 }
78 if t.contains("bool") {
79 return Schema::Object(
80 utoipa::openapi::schema::ObjectBuilder::new()
81 .schema_type(SchemaType::new(Type::Boolean))
82 .into(),
83 );
84 }
85 if t.contains("uuid") {
86 return Schema::Object(
87 utoipa::openapi::schema::ObjectBuilder::new()
88 .schema_type(SchemaType::new(Type::String))
89 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
90 utoipa::openapi::schema::KnownFormat::Uuid,
91 )))
92 .into(),
93 );
94 }
95 if t.contains("numeric")
96 || t.contains("decimal")
97 || t.contains("real")
98 || t.contains("double")
99 || t.contains("float")
100 {
101 return Schema::Object(
102 utoipa::openapi::schema::ObjectBuilder::new()
103 .schema_type(SchemaType::new(Type::Number))
104 .into(),
105 );
106 }
107 if t.contains("timestamp") || t.contains("date") {
108 return Schema::Object(
109 utoipa::openapi::schema::ObjectBuilder::new()
110 .schema_type(SchemaType::new(Type::String))
111 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
112 utoipa::openapi::schema::KnownFormat::DateTime,
113 )))
114 .into(),
115 );
116 }
117 Schema::Object(
118 utoipa::openapi::schema::ObjectBuilder::new()
119 .schema_type(SchemaType::new(Type::String))
120 .into(),
121 )
122}
123
124fn entity_body_schema(entity: &ResolvedEntity, for_create: bool) -> Schema {
127 let mut builder = utoipa::openapi::schema::ObjectBuilder::new()
128 .schema_type(SchemaType::new(Type::Object))
129 .description(Some(format!(
130 "Fields from architect._sys_columns for table {} (API uses camelCase).",
131 entity.table_id
132 )));
133 let mut required = Vec::new();
134 for col in &entity.columns {
135 if entity.sensitive_columns.contains(&col.name) {
136 continue;
137 }
138 let camel = to_camel_case(&col.name);
139 let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
140 builder = builder.property(camel.clone(), RefOr::T(prop_schema));
141 if for_create && !col.nullable && !col.has_default {
142 required.push(camel);
143 }
144 }
145 for r in &required {
146 builder = builder.required(r.clone());
147 }
148 Schema::Object(builder.into())
149}
150
151fn default_responses() -> ResponsesBuilder {
152 ResponsesBuilder::new()
153 .response("200", Response::new("OK"))
154 .response("201", Response::new("Created"))
155 .response("204", Response::new("No Content"))
156 .response("400", Response::new("Bad Request"))
157 .response("404", Response::new("Not Found"))
158}
159
160fn x_tenant_id_header() -> Parameter {
162 ParameterBuilder::new()
163 .name("X-Tenant-ID")
164 .parameter_in(ParameterIn::Header)
165 .required(Required::True)
166 .description(Some(
167 "Tenant id; must match a tenant in architect._sys_tenants (e.g. default-mode-1, default-mode-3).",
168 ))
169 .schema(Some(RefOr::T(Schema::Object(
170 utoipa::openapi::schema::ObjectBuilder::new()
171 .schema_type(SchemaType::new(Type::String))
172 .into(),
173 ))))
174 .build()
175}
176
177fn package_id_param() -> Parameter {
179 ParameterBuilder::new()
180 .name("packageId")
181 .parameter_in(ParameterIn::Path)
182 .required(Required::True)
183 .description(Some("Package id from architect._sys_packages."))
184 .schema(Some(RefOr::T(Schema::Object(
185 utoipa::openapi::schema::ObjectBuilder::new()
186 .schema_type(SchemaType::new(Type::String))
187 .into(),
188 ))))
189 .build()
190}
191
192fn list_operation(
193 entity: &ResolvedEntity,
194 op_suffix: &str,
195 include_package_id_param: bool,
196) -> Operation {
197 let mut params = vec![x_tenant_id_header()];
198 if include_package_id_param {
199 params.push(package_id_param());
200 }
201 params.extend(vec![
202 ParameterBuilder::new()
203 .name("limit")
204 .parameter_in(ParameterIn::Query)
205 .required(Required::False)
206 .description(Some("Max number of items to return"))
207 .schema(Some(RefOr::T(Schema::Object(
208 utoipa::openapi::schema::ObjectBuilder::new()
209 .schema_type(SchemaType::new(Type::Integer))
210 .into(),
211 ))))
212 .build(),
213 ParameterBuilder::new()
214 .name("offset")
215 .parameter_in(ParameterIn::Query)
216 .required(Required::False)
217 .description(Some("Number of items to skip"))
218 .schema(Some(RefOr::T(Schema::Object(
219 utoipa::openapi::schema::ObjectBuilder::new()
220 .schema_type(SchemaType::new(Type::Integer))
221 .into(),
222 ))))
223 .build(),
224 ParameterBuilder::new()
225 .name("include")
226 .parameter_in(ParameterIn::Query)
227 .required(Required::False)
228 .description(Some(
229 "Comma-separated related entity path segments to include",
230 ))
231 .schema(Some(RefOr::T(Schema::Object(
232 utoipa::openapi::schema::ObjectBuilder::new()
233 .schema_type(SchemaType::new(Type::String))
234 .into(),
235 ))))
236 .build(),
237 ]);
238 for col in &entity.columns {
239 if entity.sensitive_columns.contains(&col.name) {
240 continue;
241 }
242 let camel = to_camel_case(&col.name);
243 let schema = column_schema_from_pg_type(col.pg_type.as_deref());
244 params.push(
245 ParameterBuilder::new()
246 .name(camel)
247 .parameter_in(ParameterIn::Query)
248 .required(Required::False)
249 .description(Some(format!("Filter by {} (from _sys_columns)", col.name)))
250 .schema(Some(RefOr::T(schema)))
251 .build(),
252 );
253 }
254 OperationBuilder::new()
255 .summary(Some(format!("List {}", entity.path_segment)))
256 .description(Some(format!(
257 "List {} with optional filters, pagination (limit, offset), and includes.",
258 entity.path_segment
259 )))
260 .operation_id(Some(format!("list_{}{}", entity.path_segment, op_suffix)))
261 .parameters(Some(params))
262 .responses(default_responses().build())
263 .build()
264}
265
266fn create_operation(
267 entity: &ResolvedEntity,
268 op_suffix: &str,
269 include_package_id_param: bool,
270) -> Operation {
271 let mut params = vec![x_tenant_id_header()];
272 if include_package_id_param {
273 params.push(package_id_param());
274 }
275 let body = RequestBodyBuilder::new()
276 .description(Some(format!(
277 "JSON object with {} fields from _sys_columns (camelCase). PK may be omitted if DB default exists.",
278 entity.path_segment
279 )))
280 .content(
281 "application/json",
282 Content::new(Some(RefOr::T(entity_body_schema(entity, true)))),
283 )
284 .required(Some(Required::True))
285 .build();
286 OperationBuilder::new()
287 .summary(Some(format!("Create {}", entity.path_segment)))
288 .description(Some(format!("Create a single {}", entity.path_segment)))
289 .operation_id(Some(format!("create_{}{}", entity.path_segment, op_suffix)))
290 .parameters(Some(params))
291 .request_body(Some(body))
292 .responses(
293 ResponsesBuilder::new()
294 .response("201", Response::new("Created"))
295 .response("400", Response::new("Bad Request"))
296 .build(),
297 )
298 .build()
299}
300
301fn read_operation(
302 entity: &ResolvedEntity,
303 op_suffix: &str,
304 include_package_id_param: bool,
305) -> Operation {
306 let mut params = vec![x_tenant_id_header()];
307 if include_package_id_param {
308 params.push(package_id_param());
309 }
310 let id_param = ParameterBuilder::new()
311 .name("id")
312 .parameter_in(ParameterIn::Path)
313 .required(Required::True)
314 .description(Some(
315 "Entity ID (UUID, integer, or text depending on table PK)",
316 ))
317 .schema(Some(RefOr::T(Schema::Object(
318 utoipa::openapi::schema::ObjectBuilder::new()
319 .schema_type(SchemaType::new(Type::String))
320 .into(),
321 ))))
322 .build();
323 let include_param = ParameterBuilder::new()
324 .name("include")
325 .parameter_in(ParameterIn::Query)
326 .required(Required::False)
327 .description(Some(
328 "Comma-separated related entity path segments to include",
329 ))
330 .schema(Some(RefOr::T(Schema::Object(
331 utoipa::openapi::schema::ObjectBuilder::new()
332 .schema_type(SchemaType::new(Type::String))
333 .into(),
334 ))))
335 .build();
336 params.push(id_param);
337 params.push(include_param);
338 OperationBuilder::new()
339 .summary(Some(format!("Get {} by id", entity.path_segment)))
340 .description(Some(format!("Get a single {} by id.", entity.path_segment)))
341 .operation_id(Some(format!("read_{}{}", entity.path_segment, op_suffix)))
342 .parameters(Some(params))
343 .responses(default_responses().build())
344 .build()
345}
346
347fn update_operation(
348 entity: &ResolvedEntity,
349 op_suffix: &str,
350 include_package_id_param: bool,
351) -> Operation {
352 let mut params = vec![x_tenant_id_header()];
353 if include_package_id_param {
354 params.push(package_id_param());
355 }
356 let id_param = ParameterBuilder::new()
357 .name("id")
358 .parameter_in(ParameterIn::Path)
359 .required(Required::True)
360 .description(Some("Entity ID"))
361 .schema(Some(RefOr::T(Schema::Object(
362 utoipa::openapi::schema::ObjectBuilder::new()
363 .schema_type(SchemaType::new(Type::String))
364 .into(),
365 ))))
366 .build();
367 params.push(id_param);
368 let body = RequestBodyBuilder::new()
369 .description(Some(
370 "JSON object with fields from _sys_columns to update (camelCase, partial).",
371 ))
372 .content(
373 "application/json",
374 Content::new(Some(RefOr::T(entity_body_schema(entity, false)))),
375 )
376 .required(Some(Required::True))
377 .build();
378 OperationBuilder::new()
379 .summary(Some(format!("Update {} by id", entity.path_segment)))
380 .description(Some(format!(
381 "Update a single {} by id.",
382 entity.path_segment
383 )))
384 .operation_id(Some(format!("update_{}{}", entity.path_segment, op_suffix)))
385 .parameters(Some(params))
386 .request_body(Some(body))
387 .responses(default_responses().build())
388 .build()
389}
390
391fn delete_operation(
392 entity: &ResolvedEntity,
393 op_suffix: &str,
394 include_package_id_param: bool,
395) -> Operation {
396 let mut params = vec![x_tenant_id_header()];
397 if include_package_id_param {
398 params.push(package_id_param());
399 }
400 let id_param = ParameterBuilder::new()
401 .name("id")
402 .parameter_in(ParameterIn::Path)
403 .required(Required::True)
404 .description(Some("Entity ID"))
405 .schema(Some(RefOr::T(Schema::Object(
406 utoipa::openapi::schema::ObjectBuilder::new()
407 .schema_type(SchemaType::new(Type::String))
408 .into(),
409 ))))
410 .build();
411 params.push(id_param);
412 OperationBuilder::new()
413 .summary(Some(format!("Delete {} by id", entity.path_segment)))
414 .description(Some(format!(
415 "Delete a single {} by id.",
416 entity.path_segment
417 )))
418 .operation_id(Some(format!("delete_{}{}", entity.path_segment, op_suffix)))
419 .parameters(Some(params))
420 .responses(
421 ResponsesBuilder::new()
422 .response("204", Response::new("No Content"))
423 .response("400", Response::new("Bad Request"))
424 .response("404", Response::new("Not Found"))
425 .build(),
426 )
427 .build()
428}
429
430fn bulk_create_operation(
431 entity: &ResolvedEntity,
432 op_suffix: &str,
433 include_package_id_param: bool,
434) -> Operation {
435 let mut params = vec![x_tenant_id_header()];
436 if include_package_id_param {
437 params.push(package_id_param());
438 }
439 let item_schema = entity_body_schema(entity, true);
440 let body = RequestBodyBuilder::new()
441 .description(Some(
442 "JSON array of objects; each has shape from _sys_columns (same as create body).",
443 ))
444 .content(
445 "application/json",
446 Content::new(Some(RefOr::T(Schema::Array(
447 utoipa::openapi::schema::ArrayBuilder::new()
448 .items(RefOr::T(item_schema))
449 .build(),
450 )))),
451 )
452 .required(Some(Required::True))
453 .build();
454 OperationBuilder::new()
455 .summary(Some(format!("Bulk create {}", entity.path_segment)))
456 .description(Some(format!("Create multiple {}.", entity.path_segment)))
457 .operation_id(Some(format!(
458 "bulk_create_{}{}",
459 entity.path_segment, op_suffix
460 )))
461 .parameters(Some(params))
462 .request_body(Some(body))
463 .responses(
464 ResponsesBuilder::new()
465 .response("201", Response::new("Created"))
466 .response("400", Response::new("Bad Request"))
467 .build(),
468 )
469 .build()
470}
471
472fn bulk_update_operation(
473 entity: &ResolvedEntity,
474 op_suffix: &str,
475 include_package_id_param: bool,
476) -> Operation {
477 let mut params = vec![x_tenant_id_header()];
478 if include_package_id_param {
479 params.push(package_id_param());
480 }
481 let item_schema = entity_body_schema(entity, false);
482 let body = RequestBodyBuilder::new()
483 .description(Some(
484 "JSON array of objects; each must include id and fields from _sys_columns to update (camelCase, partial).",
485 ))
486 .content(
487 "application/json",
488 Content::new(Some(RefOr::T(Schema::Array(
489 utoipa::openapi::schema::ArrayBuilder::new()
490 .items(RefOr::T(item_schema))
491 .build(),
492 )))),
493 )
494 .required(Some(Required::True))
495 .build();
496 OperationBuilder::new()
497 .summary(Some(format!("Bulk update {}", entity.path_segment)))
498 .description(Some(format!("Update multiple {}.", entity.path_segment)))
499 .operation_id(Some(format!(
500 "bulk_update_{}{}",
501 entity.path_segment, op_suffix
502 )))
503 .parameters(Some(params))
504 .request_body(Some(body))
505 .responses(default_responses().build())
506 .build()
507}
508
509fn bulk_delete_operation(
510 entity: &ResolvedEntity,
511 op_suffix: &str,
512 include_package_id_param: bool,
513) -> Operation {
514 let mut params = vec![x_tenant_id_header()];
515 if include_package_id_param {
516 params.push(package_id_param());
517 }
518 let ids_schema = Schema::Array(
520 utoipa::openapi::schema::ArrayBuilder::new()
521 .items(RefOr::T(Schema::Object(
522 ObjectBuilder::new()
523 .schema_type(SchemaType::new(Type::String))
524 .build(),
525 )))
526 .build(),
527 );
528 let body_schema = ObjectBuilder::new()
529 .schema_type(SchemaType::new(Type::Object))
530 .property("ids", RefOr::T(ids_schema))
531 .required("ids")
532 .build();
533 let body = RequestBodyBuilder::new()
534 .description(Some(
535 "Object with an `ids` array of primary-key values to delete (a bare JSON array of ids is also accepted).",
536 ))
537 .content(
538 "application/json",
539 Content::new(Some(RefOr::T(Schema::Object(body_schema)))),
540 )
541 .required(Some(Required::True))
542 .build();
543 OperationBuilder::new()
544 .summary(Some(format!("Bulk delete {}", entity.path_segment)))
545 .description(Some(format!(
546 "Delete multiple {} by id. Ids that do not exist are skipped.",
547 entity.path_segment
548 )))
549 .operation_id(Some(format!(
550 "bulk_delete_{}{}",
551 entity.path_segment, op_suffix
552 )))
553 .parameters(Some(params))
554 .request_body(Some(body))
555 .responses(default_responses().build())
556 .build()
557}
558
559fn graph_child_body_schema(child: &ResolvedEntity, fk_column: &str) -> Schema {
563 let mut builder = ObjectBuilder::new()
564 .schema_type(SchemaType::new(Type::Object))
565 .description(Some(format!(
566 "Fields for {} (camelCase). The relationship column '{}' is set automatically from the parent and must be omitted.",
567 child.path_segment,
568 to_camel_case(fk_column)
569 )));
570 let mut required = Vec::new();
571 for col in &child.columns {
572 if child.sensitive_columns.contains(&col.name) || col.name == *fk_column {
573 continue;
574 }
575 let camel = to_camel_case(&col.name);
576 let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
577 builder = builder.property(camel.clone(), RefOr::T(prop_schema));
578 if !col.nullable && !col.has_default {
579 required.push(camel);
580 }
581 }
582 for r in &required {
583 builder = builder.required(r.clone());
584 }
585 Schema::Object(builder.into())
586}
587
588fn graph_create_operation(
591 entity: &ResolvedEntity,
592 model: &ResolvedModel,
593 op_suffix: &str,
594 include_package_id_param: bool,
595) -> Operation {
596 let mut params = vec![x_tenant_id_header()];
597 if include_package_id_param {
598 params.push(package_id_param());
599 }
600
601 let mut include_builder = ObjectBuilder::new()
603 .schema_type(SchemaType::new(Type::Object))
604 .description(Some(
605 "Related FK-children inserted atomically with the parent. Each key is a to-many include name (same as ?include=); the value is a single object or an array.".to_string(),
606 ));
607 for inc in &entity.includes {
608 if !matches!(inc.direction, IncludeDirection::ToMany) {
609 continue;
610 }
611 let Some(child) = model.entity_by_path(&inc.related_path_segment) else {
612 continue;
613 };
614 if !child.operations.iter().any(|o| o == "create") {
615 continue;
616 }
617 let child_schema = graph_child_body_schema(child, &inc.their_key_column);
618 let one_of = OneOfBuilder::new()
619 .item(RefOr::T(child_schema.clone()))
620 .item(RefOr::T(Schema::Array(
621 ArrayBuilder::new().items(RefOr::T(child_schema)).build(),
622 )))
623 .build();
624 include_builder =
625 include_builder.property(inc.name.clone(), RefOr::T(Schema::OneOf(one_of)));
626 }
627
628 let req_schema = ObjectBuilder::new()
629 .schema_type(SchemaType::new(Type::Object))
630 .property("data", RefOr::T(entity_body_schema(entity, true)))
631 .required("data")
632 .property("include", RefOr::T(Schema::Object(include_builder.into())))
633 .build();
634
635 let body = RequestBodyBuilder::new()
636 .description(Some(
637 "Parent record under 'data' plus optional nested FK-children under 'include'. Inserted atomically in one transaction.",
638 ))
639 .content(
640 "application/json",
641 Content::new(Some(RefOr::T(Schema::Object(req_schema)))),
642 )
643 .required(Some(Required::True))
644 .build();
645
646 OperationBuilder::new()
647 .summary(Some(format!(
648 "Create {} with related children (atomic)",
649 entity.path_segment
650 )))
651 .description(Some(format!(
652 "Insert a {} and its to-many FK-children in a single transaction. Each child's FK is filled from the new parent id; any failure rolls the whole request back.",
653 entity.path_segment
654 )))
655 .operation_id(Some(format!(
656 "create_graph_{}{}",
657 entity.path_segment, op_suffix
658 )))
659 .parameters(Some(params))
660 .request_body(Some(body))
661 .responses(
662 ResponsesBuilder::new()
663 .response("201", Response::new("Created"))
664 .response("400", Response::new("Bad Request"))
665 .response("422", Response::new("Validation Error"))
666 .build(),
667 )
668 .build()
669}
670
671fn add_entity_paths(
675 mut builder: PathsBuilder,
676 base: &str,
677 model: &ResolvedModel,
678 use_package_param: bool,
679 package_id_literal: Option<&str>,
680) -> PathsBuilder {
681 let path_prefix = if use_package_param {
682 match package_id_literal {
683 Some(pkg) => format!("{}/package/{}", base, pkg),
684 None => format!("{}/package/{{packageId}}", base),
685 }
686 } else {
687 base.to_string()
688 };
689 let op_suffix = if use_package_param { "_package" } else { "" };
690
691 for entity in &model.entities {
692 let seg = &entity.path_segment;
693 let list_path = format!("{}/{}", path_prefix, seg);
694 let by_id_path = format!("{}/{}/{{id}}", path_prefix, seg);
695 let bulk_path = format!("{}/{}/bulk", path_prefix, seg);
696
697 let has_list = entity.operations.iter().any(|o| o == "read");
698 let has_create = entity.operations.iter().any(|o| o == "create");
699 if has_list || has_create {
700 let mut list_item = PathItemBuilder::new();
701 if has_list {
702 list_item = list_item.operation(
703 HttpMethod::Get,
704 list_operation(entity, op_suffix, use_package_param),
705 );
706 }
707 if has_create {
708 list_item = list_item.operation(
709 HttpMethod::Post,
710 create_operation(entity, op_suffix, use_package_param),
711 );
712 }
713 builder = builder.path(list_path, list_item.build());
714 }
715
716 let has_read = entity.operations.iter().any(|o| o == "read");
717 let has_update = entity.operations.iter().any(|o| o == "update");
718 let has_delete = entity.operations.iter().any(|o| o == "delete");
719 if has_read || has_update || has_delete {
720 let mut by_id_item = PathItemBuilder::new();
721 if has_read {
722 by_id_item = by_id_item.operation(
723 HttpMethod::Get,
724 read_operation(entity, op_suffix, use_package_param),
725 );
726 }
727 if has_update {
728 by_id_item = by_id_item.operation(
729 HttpMethod::Patch,
730 update_operation(entity, op_suffix, use_package_param),
731 );
732 }
733 if has_delete {
734 by_id_item = by_id_item.operation(
735 HttpMethod::Delete,
736 delete_operation(entity, op_suffix, use_package_param),
737 );
738 }
739 builder = builder.path(by_id_path, by_id_item.build());
740 }
741
742 let has_bulk_create = entity.operations.iter().any(|o| o == "bulk_create");
743 let has_bulk_update = entity.operations.iter().any(|o| o == "bulk_update");
744 let has_bulk_delete = entity.operations.iter().any(|o| o == "bulk_delete");
745 if has_bulk_create || has_bulk_update || has_bulk_delete {
746 let mut bulk_item = PathItemBuilder::new();
747 if has_bulk_create {
748 bulk_item = bulk_item.operation(
749 HttpMethod::Post,
750 bulk_create_operation(entity, op_suffix, use_package_param),
751 );
752 }
753 if has_bulk_update {
754 bulk_item = bulk_item.operation(
755 HttpMethod::Patch,
756 bulk_update_operation(entity, op_suffix, use_package_param),
757 );
758 }
759 if has_bulk_delete {
760 bulk_item = bulk_item.operation(
761 HttpMethod::Delete,
762 bulk_delete_operation(entity, op_suffix, use_package_param),
763 );
764 }
765 builder = builder.path(bulk_path, bulk_item.build());
766 }
767
768 let has_graph = entity.operations.iter().any(|o| o == "create_graph")
772 && entity
773 .includes
774 .iter()
775 .any(|i| matches!(i.direction, IncludeDirection::ToMany));
776 if has_graph {
777 let graph_path = format!("{}/{}/graph", path_prefix, seg);
778 builder = builder.path(
779 graph_path,
780 PathItemBuilder::new()
781 .operation(
782 HttpMethod::Post,
783 graph_create_operation(entity, model, op_suffix, use_package_param),
784 )
785 .build(),
786 );
787 }
788
789 if !entity.extensible_columns.is_empty() {
792 let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity, op_suffix);
793 builder = builder.path(
794 format!("{}/{}/extensible-fields", path_prefix, seg),
795 PathItemBuilder::new()
796 .operation(HttpMethod::Get, xf_get)
797 .operation(HttpMethod::Put, xf_put)
798 .operation(HttpMethod::Delete, xf_delete)
799 .build(),
800 );
801 let (idx_get, idx_post) = extensible_indexes_operations(entity, op_suffix);
802 builder = builder.path(
803 format!("{}/{}/extensible-fields/indexes", path_prefix, seg),
804 PathItemBuilder::new()
805 .operation(HttpMethod::Get, idx_get)
806 .operation(HttpMethod::Post, idx_post)
807 .build(),
808 );
809 }
810 }
811 builder
812}
813
814fn extensible_fields_operations(
816 entity: &ResolvedEntity,
817 op_suffix: &str,
818) -> (Operation, Operation, Operation) {
819 let seg = &entity.path_segment;
820 let get = OperationBuilder::new()
821 .summary(Some("Get extensible-field registry"))
822 .description(Some(
823 "Return the tenant's extensible-field registry document for this entity (or {} when unset).",
824 ))
825 .operation_id(Some(format!("get_extensible_fields_{}{}", seg, op_suffix)))
826 .parameters(Some(vec![x_tenant_id_header()]))
827 .responses(default_responses().build())
828 .build();
829 let put = OperationBuilder::new()
830 .summary(Some("Replace extensible-field registry"))
831 .description(Some(
832 "Validate and replace the tenant's registry. Body maps each extensible column to its field definitions, e.g. {\"attributes\":[{\"key\":\"warrantyMonths\",\"type\":\"int\",\"filterable\":true,\"sortable\":true}]}.",
833 ))
834 .operation_id(Some(format!("put_extensible_fields_{}{}", seg, op_suffix)))
835 .parameters(Some(vec![x_tenant_id_header()]))
836 .request_body(Some(
837 RequestBodyBuilder::new()
838 .description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
839 .content(
840 "application/json",
841 Content::new(Some(RefOr::T(Schema::Object(
842 ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
843 )))),
844 )
845 .required(Some(Required::True))
846 .build(),
847 ))
848 .responses(default_responses().build())
849 .build();
850 let delete = OperationBuilder::new()
851 .summary(Some("Clear extensible-field registry"))
852 .description(Some(
853 "Delete the tenant's registry document for this entity.",
854 ))
855 .operation_id(Some(format!(
856 "delete_extensible_fields_{}{}",
857 seg, op_suffix
858 )))
859 .parameters(Some(vec![x_tenant_id_header()]))
860 .responses(default_responses().build())
861 .build();
862 (get, put, delete)
863}
864
865fn extensible_indexes_operations(
867 entity: &ResolvedEntity,
868 op_suffix: &str,
869) -> (Operation, Operation) {
870 let seg = &entity.path_segment;
871 let get = OperationBuilder::new()
872 .summary(Some("Suggested indexes for extensible fields"))
873 .description(Some(
874 "Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
875 ))
876 .operation_id(Some(format!("get_extensible_field_indexes_{}{}", seg, op_suffix)))
877 .parameters(Some(vec![x_tenant_id_header()]))
878 .responses(default_responses().build())
879 .build();
880 let post = OperationBuilder::new()
881 .summary(Some("Apply extensible-field indexes"))
882 .description(Some(
883 "Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
884 ))
885 .operation_id(Some(format!("apply_extensible_field_indexes_{}{}", seg, op_suffix)))
886 .parameters(Some(vec![x_tenant_id_header()]))
887 .responses(default_responses().build())
888 .build();
889 (get, post)
890}
891
892fn kv_namespace_param() -> Parameter {
893 ParameterBuilder::new()
894 .name("namespace")
895 .parameter_in(ParameterIn::Path)
896 .required(Required::True)
897 .description(Some("KV store namespace (from _sys_kv_stores)."))
898 .schema(Some(RefOr::T(Schema::Object(
899 utoipa::openapi::schema::ObjectBuilder::new()
900 .schema_type(SchemaType::new(Type::String))
901 .into(),
902 ))))
903 .build()
904}
905
906fn kv_list_keys_operation() -> Operation {
907 OperationBuilder::new()
908 .summary(Some("List KV keys in namespace"))
909 .description(Some(
910 "List all keys and values in the given package and namespace.",
911 ))
912 .operation_id(Some("kv_list_keys"))
913 .parameters(Some(vec![
914 x_tenant_id_header(),
915 package_id_param(),
916 kv_namespace_param(),
917 ]))
918 .responses(default_responses().build())
919 .build()
920}
921
922fn kv_key_param() -> Parameter {
923 ParameterBuilder::new()
924 .name("key")
925 .parameter_in(ParameterIn::Path)
926 .required(Required::True)
927 .description(Some("KV key"))
928 .schema(Some(RefOr::T(Schema::Object(
929 utoipa::openapi::schema::ObjectBuilder::new()
930 .schema_type(SchemaType::new(Type::String))
931 .into(),
932 ))))
933 .build()
934}
935
936fn kv_key_operations() -> (Operation, Operation, Operation) {
937 let get_op = OperationBuilder::new()
938 .summary(Some("Get KV value by key"))
939 .description(Some("Get value for key in package and namespace."))
940 .operation_id(Some("kv_get"))
941 .parameters(Some(vec![
942 x_tenant_id_header(),
943 package_id_param(),
944 kv_namespace_param(),
945 kv_key_param(),
946 ]))
947 .responses(default_responses().build())
948 .build();
949
950 let put_op = OperationBuilder::new()
951 .summary(Some("Set KV value (upsert)"))
952 .description(Some(
953 "Set or overwrite value for key. Body is arbitrary JSON.",
954 ))
955 .operation_id(Some("kv_put"))
956 .parameters(Some(vec![
957 x_tenant_id_header(),
958 package_id_param(),
959 kv_namespace_param(),
960 kv_key_param(),
961 ]))
962 .request_body(Some(
963 RequestBodyBuilder::new()
964 .description(Some("JSON value (string, number, object, or array)"))
965 .content(
966 "application/json",
967 Content::new(Some(RefOr::T(json_object_schema()))),
968 )
969 .required(Some(Required::True))
970 .build(),
971 ))
972 .responses(
973 ResponsesBuilder::new()
974 .response("200", Response::new("OK"))
975 .response("400", Response::new("Bad Request"))
976 .build(),
977 )
978 .build();
979
980 let delete_op = OperationBuilder::new()
981 .summary(Some("Delete KV key"))
982 .description(Some("Delete key. Returns 204 No Content."))
983 .operation_id(Some("kv_delete"))
984 .parameters(Some(vec![
985 x_tenant_id_header(),
986 package_id_param(),
987 kv_namespace_param(),
988 kv_key_param(),
989 ]))
990 .responses(
991 ResponsesBuilder::new()
992 .response("204", Response::new("No Content"))
993 .response("404", Response::new("Not Found"))
994 .build(),
995 )
996 .build();
997
998 (get_op, put_op, delete_op)
999}
1000
1001fn add_kv_paths(
1003 mut builder: PathsBuilder,
1004 base: &str,
1005 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1006) -> PathsBuilder {
1007 for (package_id, stores) in package_kv_stores {
1008 if stores.is_empty() {
1009 continue;
1010 }
1011 let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
1012 let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
1013
1014 let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
1015 builder = builder.path(list_path, list_item.build());
1016
1017 let (get_op, put_op, delete_op) = kv_key_operations();
1018 let key_item = PathItemBuilder::new()
1019 .operation(HttpMethod::Get, get_op)
1020 .operation(HttpMethod::Put, put_op)
1021 .operation(HttpMethod::Delete, delete_op);
1022 builder = builder.path(key_path, key_item.build());
1023 }
1024 builder
1025}
1026
1027fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
1029 let install_path = format!("{}/config/package", base);
1030 let install_op = OperationBuilder::new()
1031 .summary(Some("Install package"))
1032 .description(Some(
1033 "Upload a package zip. Zip must contain manifest.json (id, name, version, schema) at root and config JSON files. Use multipart/form-data with field 'file' or 'package' (ZIP file).",
1034 ))
1035 .operation_id(Some("config_install_package"))
1036 .parameters(Some(vec![x_tenant_id_header()]))
1037 .request_body(Some(
1038 RequestBodyBuilder::new()
1039 .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
1040 .content(
1041 "multipart/form-data",
1042 Content::new(Some(RefOr::T(Schema::Object(
1043 ObjectBuilder::new()
1044 .schema_type(SchemaType::new(Type::Object))
1045 .property(
1046 "file",
1047 Schema::Object(
1048 ObjectBuilder::new()
1049 .schema_type(SchemaType::new(Type::String))
1050 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
1051 utoipa::openapi::schema::KnownFormat::Binary,
1052 )))
1053 .description(Some("ZIP file (manifest.json + config JSONs)"))
1054 .into(),
1055 ),
1056 )
1057 .into(),
1058 )))),
1059 )
1060 .required(Some(Required::True))
1061 .build(),
1062 ))
1063 .responses(
1064 ResponsesBuilder::new()
1065 .response("200", Response::new("OK"))
1066 .response("400", Response::new("Bad Request"))
1067 .build(),
1068 )
1069 .build();
1070 let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
1071 builder = builder.path(install_path, install_item.build());
1072
1073 let uninstall_path = format!("{}/config/package/{{packageId}}", base);
1074 let uninstall_op = OperationBuilder::new()
1075 .summary(Some("Uninstall package"))
1076 .description(Some(
1077 "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
1078 ))
1079 .operation_id(Some("config_uninstall_package"))
1080 .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
1081 .responses(
1082 ResponsesBuilder::new()
1083 .response("200", Response::new("OK"))
1084 .response("404", Response::new("Not Found"))
1085 .build(),
1086 )
1087 .build();
1088 let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
1089 builder = builder.path(uninstall_path, uninstall_item.build());
1090
1091 let config_kinds = [
1092 ("schemas", "Schema definitions"),
1093 ("enums", "Enum types"),
1094 ("tables", "Table definitions"),
1095 ("columns", "Column definitions"),
1096 ("indexes", "Index definitions"),
1097 ("relationships", "Relationship definitions"),
1098 ("api_entities", "API entity definitions"),
1099 ("kv_stores", "KV store definitions"),
1100 ];
1101 for (kind, description) in config_kinds {
1102 let path = format!("{}/config/{}", base, kind);
1103 let get_op = OperationBuilder::new()
1104 .summary(Some(format!("Get {}", kind)))
1105 .description(Some(format!(
1106 "Get {} (from _sys_{}). {}",
1107 description, kind, "X-Tenant-ID required."
1108 )))
1109 .operation_id(Some(format!("config_get_{}", kind)))
1110 .parameters(Some(vec![x_tenant_id_header()]))
1111 .responses(default_responses().build())
1112 .build();
1113 let post_body = RequestBodyBuilder::new()
1114 .description(Some(format!("JSON array of {} records.", description)))
1115 .content(
1116 "application/json",
1117 Content::new(Some(RefOr::T(Schema::Array(
1118 utoipa::openapi::schema::ArrayBuilder::new()
1119 .items(RefOr::T(json_object_schema()))
1120 .into(),
1121 )))),
1122 )
1123 .required(Some(Required::True))
1124 .build();
1125 let post_op = OperationBuilder::new()
1126 .summary(Some(format!("Replace {}", kind)))
1127 .description(Some(format!(
1128 "Replace {} for the default package. Runs migrations when rows change.",
1129 kind
1130 )))
1131 .operation_id(Some(format!("config_post_{}", kind)))
1132 .parameters(Some(vec![x_tenant_id_header()]))
1133 .request_body(Some(post_body))
1134 .responses(default_responses().build())
1135 .build();
1136 let item = PathItemBuilder::new()
1137 .operation(HttpMethod::Get, get_op)
1138 .operation(HttpMethod::Post, post_op);
1139 builder = builder.path(path, item.build());
1140 }
1141 builder
1142}
1143
1144pub fn build_spec(
1147 default_model: &ResolvedModel,
1148 base_path: &str,
1149 package_models: &HashMap<String, ResolvedModel>,
1150 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1151) -> OpenApi {
1152 let server = build_server();
1153 let mut builder = PathsBuilder::new();
1154 builder = add_config_paths(builder, base_path);
1155 builder = add_entity_paths(builder, base_path, default_model, false, None);
1156 for (package_id, model) in package_models {
1157 if !model.entities.is_empty() {
1158 builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
1159 }
1160 }
1161 builder = add_kv_paths(builder, base_path, package_kv_stores);
1162 let paths = builder.build();
1163 OpenApiBuilder::new()
1164 .info(
1165 Info::builder()
1166 .title("Architect API")
1167 .version(env!("CARGO_PKG_VERSION"))
1168 .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
1169 .build(),
1170 )
1171 .servers(Some(vec![server]))
1172 .paths(paths)
1173 .build()
1174}
1175
1176pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
1180 let default_model = state.model.read().expect("model read lock").clone();
1181 let base_path = "/api/v1";
1182
1183 let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
1184 let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
1185 let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
1186 for package_id in package_ids {
1187 if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
1188 if let Ok(model) = resolve(&config) {
1189 package_models.insert(package_id.clone(), model);
1190 }
1191 package_kv_stores.insert(package_id, config.kv_stores);
1192 }
1193 }
1194
1195 let spec = build_spec(
1196 &default_model,
1197 base_path,
1198 &package_models,
1199 &package_kv_stores,
1200 );
1201 Json(spec)
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207 use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
1208 use std::collections::{HashMap, HashSet};
1209
1210 fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
1211 ResolvedEntity {
1212 table_id: seg.to_string(),
1213 schema_name: "public".into(),
1214 table_name: seg.to_string(),
1215 path_segment: seg.to_string(),
1216 pk_columns: vec!["id".into()],
1217 pk_type: PkType::Uuid,
1218 columns: vec![],
1219 operations: vec![
1220 "read".into(),
1221 "create".into(),
1222 "update".into(),
1223 "delete".into(),
1224 ],
1225 sensitive_columns: HashSet::new(),
1226 includes: vec![],
1227 validation: HashMap::new(),
1228 events: vec![],
1229 archive_field: None,
1230 package_id: "_default".into(),
1231 audit_log: false,
1232 global: false,
1233 parent_ref_column: None,
1234 versioning: None,
1235 mcp: None,
1236 extensible_columns,
1237 }
1238 }
1239
1240 #[test]
1241 fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
1242 let model = ResolvedModel {
1243 entities: vec![
1244 entity("products", vec!["attributes".into()]),
1245 entity("orders", vec![]),
1246 ],
1247 entity_by_path: HashMap::new(),
1248 reports: HashMap::new(),
1249 };
1250 let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
1251 let json = serde_json::to_string(&spec).expect("serialize spec");
1252
1253 assert!(json.contains("/api/v1/products/extensible-fields"));
1255 assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
1256 assert!(!json.contains("/api/v1/orders/extensible-fields"));
1258 }
1259
1260 #[test]
1261 fn spec_lists_package_scoped_extensible_field_paths() {
1262 let default_model = ResolvedModel {
1263 entities: vec![entity("products", vec!["attributes".into()])],
1264 entity_by_path: HashMap::new(),
1265 reports: HashMap::new(),
1266 };
1267 let mut package_models = HashMap::new();
1268 package_models.insert(
1269 "billing".to_string(),
1270 ResolvedModel {
1271 entities: vec![entity("invoices", vec!["meta".into()])],
1272 entity_by_path: HashMap::new(),
1273 reports: HashMap::new(),
1274 },
1275 );
1276 let spec = build_spec(&default_model, "/api/v1", &package_models, &HashMap::new());
1277 let json = serde_json::to_string(&spec).expect("serialize spec");
1278
1279 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields"));
1281 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields/indexes"));
1282 }
1283}