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 graph_child_body_schema(child: &ResolvedEntity, fk_column: &str) -> Schema {
513 let mut builder = ObjectBuilder::new()
514 .schema_type(SchemaType::new(Type::Object))
515 .description(Some(format!(
516 "Fields for {} (camelCase). The relationship column '{}' is set automatically from the parent and must be omitted.",
517 child.path_segment,
518 to_camel_case(fk_column)
519 )));
520 let mut required = Vec::new();
521 for col in &child.columns {
522 if child.sensitive_columns.contains(&col.name) || col.name == *fk_column {
523 continue;
524 }
525 let camel = to_camel_case(&col.name);
526 let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
527 builder = builder.property(camel.clone(), RefOr::T(prop_schema));
528 if !col.nullable && !col.has_default {
529 required.push(camel);
530 }
531 }
532 for r in &required {
533 builder = builder.required(r.clone());
534 }
535 Schema::Object(builder.into())
536}
537
538fn graph_create_operation(
541 entity: &ResolvedEntity,
542 model: &ResolvedModel,
543 op_suffix: &str,
544 include_package_id_param: bool,
545) -> Operation {
546 let mut params = vec![x_tenant_id_header()];
547 if include_package_id_param {
548 params.push(package_id_param());
549 }
550
551 let mut include_builder = ObjectBuilder::new()
553 .schema_type(SchemaType::new(Type::Object))
554 .description(Some(
555 "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(),
556 ));
557 for inc in &entity.includes {
558 if !matches!(inc.direction, IncludeDirection::ToMany) {
559 continue;
560 }
561 let Some(child) = model.entity_by_path(&inc.related_path_segment) else {
562 continue;
563 };
564 if !child.operations.iter().any(|o| o == "create") {
565 continue;
566 }
567 let child_schema = graph_child_body_schema(child, &inc.their_key_column);
568 let one_of = OneOfBuilder::new()
569 .item(RefOr::T(child_schema.clone()))
570 .item(RefOr::T(Schema::Array(
571 ArrayBuilder::new().items(RefOr::T(child_schema)).build(),
572 )))
573 .build();
574 include_builder =
575 include_builder.property(inc.name.clone(), RefOr::T(Schema::OneOf(one_of)));
576 }
577
578 let req_schema = ObjectBuilder::new()
579 .schema_type(SchemaType::new(Type::Object))
580 .property("data", RefOr::T(entity_body_schema(entity, true)))
581 .required("data")
582 .property("include", RefOr::T(Schema::Object(include_builder.into())))
583 .build();
584
585 let body = RequestBodyBuilder::new()
586 .description(Some(
587 "Parent record under 'data' plus optional nested FK-children under 'include'. Inserted atomically in one transaction.",
588 ))
589 .content(
590 "application/json",
591 Content::new(Some(RefOr::T(Schema::Object(req_schema)))),
592 )
593 .required(Some(Required::True))
594 .build();
595
596 OperationBuilder::new()
597 .summary(Some(format!(
598 "Create {} with related children (atomic)",
599 entity.path_segment
600 )))
601 .description(Some(format!(
602 "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.",
603 entity.path_segment
604 )))
605 .operation_id(Some(format!(
606 "create_graph_{}{}",
607 entity.path_segment, op_suffix
608 )))
609 .parameters(Some(params))
610 .request_body(Some(body))
611 .responses(
612 ResponsesBuilder::new()
613 .response("201", Response::new("Created"))
614 .response("400", Response::new("Bad Request"))
615 .response("422", Response::new("Validation Error"))
616 .build(),
617 )
618 .build()
619}
620
621fn add_entity_paths(
625 mut builder: PathsBuilder,
626 base: &str,
627 model: &ResolvedModel,
628 use_package_param: bool,
629 package_id_literal: Option<&str>,
630) -> PathsBuilder {
631 let path_prefix = if use_package_param {
632 match package_id_literal {
633 Some(pkg) => format!("{}/package/{}", base, pkg),
634 None => format!("{}/package/{{packageId}}", base),
635 }
636 } else {
637 base.to_string()
638 };
639 let op_suffix = if use_package_param { "_package" } else { "" };
640
641 for entity in &model.entities {
642 let seg = &entity.path_segment;
643 let list_path = format!("{}/{}", path_prefix, seg);
644 let by_id_path = format!("{}/{}/{{id}}", path_prefix, seg);
645 let bulk_path = format!("{}/{}/bulk", path_prefix, seg);
646
647 let has_list = entity.operations.iter().any(|o| o == "read");
648 let has_create = entity.operations.iter().any(|o| o == "create");
649 if has_list || has_create {
650 let mut list_item = PathItemBuilder::new();
651 if has_list {
652 list_item = list_item.operation(
653 HttpMethod::Get,
654 list_operation(entity, op_suffix, use_package_param),
655 );
656 }
657 if has_create {
658 list_item = list_item.operation(
659 HttpMethod::Post,
660 create_operation(entity, op_suffix, use_package_param),
661 );
662 }
663 builder = builder.path(list_path, list_item.build());
664 }
665
666 let has_read = entity.operations.iter().any(|o| o == "read");
667 let has_update = entity.operations.iter().any(|o| o == "update");
668 let has_delete = entity.operations.iter().any(|o| o == "delete");
669 if has_read || has_update || has_delete {
670 let mut by_id_item = PathItemBuilder::new();
671 if has_read {
672 by_id_item = by_id_item.operation(
673 HttpMethod::Get,
674 read_operation(entity, op_suffix, use_package_param),
675 );
676 }
677 if has_update {
678 by_id_item = by_id_item.operation(
679 HttpMethod::Patch,
680 update_operation(entity, op_suffix, use_package_param),
681 );
682 }
683 if has_delete {
684 by_id_item = by_id_item.operation(
685 HttpMethod::Delete,
686 delete_operation(entity, op_suffix, use_package_param),
687 );
688 }
689 builder = builder.path(by_id_path, by_id_item.build());
690 }
691
692 let has_bulk_create = entity.operations.iter().any(|o| o == "bulk_create");
693 let has_bulk_update = entity.operations.iter().any(|o| o == "bulk_update");
694 if has_bulk_create || has_bulk_update {
695 let mut bulk_item = PathItemBuilder::new();
696 if has_bulk_create {
697 bulk_item = bulk_item.operation(
698 HttpMethod::Post,
699 bulk_create_operation(entity, op_suffix, use_package_param),
700 );
701 }
702 if has_bulk_update {
703 bulk_item = bulk_item.operation(
704 HttpMethod::Patch,
705 bulk_update_operation(entity, op_suffix, use_package_param),
706 );
707 }
708 builder = builder.path(bulk_path, bulk_item.build());
709 }
710
711 let has_graph = entity.operations.iter().any(|o| o == "create_graph")
715 && entity
716 .includes
717 .iter()
718 .any(|i| matches!(i.direction, IncludeDirection::ToMany));
719 if has_graph {
720 let graph_path = format!("{}/{}/graph", path_prefix, seg);
721 builder = builder.path(
722 graph_path,
723 PathItemBuilder::new()
724 .operation(
725 HttpMethod::Post,
726 graph_create_operation(entity, model, op_suffix, use_package_param),
727 )
728 .build(),
729 );
730 }
731
732 if !entity.extensible_columns.is_empty() {
735 let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity, op_suffix);
736 builder = builder.path(
737 format!("{}/{}/extensible-fields", path_prefix, seg),
738 PathItemBuilder::new()
739 .operation(HttpMethod::Get, xf_get)
740 .operation(HttpMethod::Put, xf_put)
741 .operation(HttpMethod::Delete, xf_delete)
742 .build(),
743 );
744 let (idx_get, idx_post) = extensible_indexes_operations(entity, op_suffix);
745 builder = builder.path(
746 format!("{}/{}/extensible-fields/indexes", path_prefix, seg),
747 PathItemBuilder::new()
748 .operation(HttpMethod::Get, idx_get)
749 .operation(HttpMethod::Post, idx_post)
750 .build(),
751 );
752 }
753 }
754 builder
755}
756
757fn extensible_fields_operations(
759 entity: &ResolvedEntity,
760 op_suffix: &str,
761) -> (Operation, Operation, Operation) {
762 let seg = &entity.path_segment;
763 let get = OperationBuilder::new()
764 .summary(Some("Get extensible-field registry"))
765 .description(Some(
766 "Return the tenant's extensible-field registry document for this entity (or {} when unset).",
767 ))
768 .operation_id(Some(format!("get_extensible_fields_{}{}", seg, op_suffix)))
769 .parameters(Some(vec![x_tenant_id_header()]))
770 .responses(default_responses().build())
771 .build();
772 let put = OperationBuilder::new()
773 .summary(Some("Replace extensible-field registry"))
774 .description(Some(
775 "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}]}.",
776 ))
777 .operation_id(Some(format!("put_extensible_fields_{}{}", seg, op_suffix)))
778 .parameters(Some(vec![x_tenant_id_header()]))
779 .request_body(Some(
780 RequestBodyBuilder::new()
781 .description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
782 .content(
783 "application/json",
784 Content::new(Some(RefOr::T(Schema::Object(
785 ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
786 )))),
787 )
788 .required(Some(Required::True))
789 .build(),
790 ))
791 .responses(default_responses().build())
792 .build();
793 let delete = OperationBuilder::new()
794 .summary(Some("Clear extensible-field registry"))
795 .description(Some(
796 "Delete the tenant's registry document for this entity.",
797 ))
798 .operation_id(Some(format!(
799 "delete_extensible_fields_{}{}",
800 seg, op_suffix
801 )))
802 .parameters(Some(vec![x_tenant_id_header()]))
803 .responses(default_responses().build())
804 .build();
805 (get, put, delete)
806}
807
808fn extensible_indexes_operations(
810 entity: &ResolvedEntity,
811 op_suffix: &str,
812) -> (Operation, Operation) {
813 let seg = &entity.path_segment;
814 let get = OperationBuilder::new()
815 .summary(Some("Suggested indexes for extensible fields"))
816 .description(Some(
817 "Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
818 ))
819 .operation_id(Some(format!("get_extensible_field_indexes_{}{}", seg, op_suffix)))
820 .parameters(Some(vec![x_tenant_id_header()]))
821 .responses(default_responses().build())
822 .build();
823 let post = OperationBuilder::new()
824 .summary(Some("Apply extensible-field indexes"))
825 .description(Some(
826 "Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
827 ))
828 .operation_id(Some(format!("apply_extensible_field_indexes_{}{}", seg, op_suffix)))
829 .parameters(Some(vec![x_tenant_id_header()]))
830 .responses(default_responses().build())
831 .build();
832 (get, post)
833}
834
835fn kv_namespace_param() -> Parameter {
836 ParameterBuilder::new()
837 .name("namespace")
838 .parameter_in(ParameterIn::Path)
839 .required(Required::True)
840 .description(Some("KV store namespace (from _sys_kv_stores)."))
841 .schema(Some(RefOr::T(Schema::Object(
842 utoipa::openapi::schema::ObjectBuilder::new()
843 .schema_type(SchemaType::new(Type::String))
844 .into(),
845 ))))
846 .build()
847}
848
849fn kv_list_keys_operation() -> Operation {
850 OperationBuilder::new()
851 .summary(Some("List KV keys in namespace"))
852 .description(Some(
853 "List all keys and values in the given package and namespace.",
854 ))
855 .operation_id(Some("kv_list_keys"))
856 .parameters(Some(vec![
857 x_tenant_id_header(),
858 package_id_param(),
859 kv_namespace_param(),
860 ]))
861 .responses(default_responses().build())
862 .build()
863}
864
865fn kv_key_param() -> Parameter {
866 ParameterBuilder::new()
867 .name("key")
868 .parameter_in(ParameterIn::Path)
869 .required(Required::True)
870 .description(Some("KV key"))
871 .schema(Some(RefOr::T(Schema::Object(
872 utoipa::openapi::schema::ObjectBuilder::new()
873 .schema_type(SchemaType::new(Type::String))
874 .into(),
875 ))))
876 .build()
877}
878
879fn kv_key_operations() -> (Operation, Operation, Operation) {
880 let get_op = OperationBuilder::new()
881 .summary(Some("Get KV value by key"))
882 .description(Some("Get value for key in package and namespace."))
883 .operation_id(Some("kv_get"))
884 .parameters(Some(vec![
885 x_tenant_id_header(),
886 package_id_param(),
887 kv_namespace_param(),
888 kv_key_param(),
889 ]))
890 .responses(default_responses().build())
891 .build();
892
893 let put_op = OperationBuilder::new()
894 .summary(Some("Set KV value (upsert)"))
895 .description(Some(
896 "Set or overwrite value for key. Body is arbitrary JSON.",
897 ))
898 .operation_id(Some("kv_put"))
899 .parameters(Some(vec![
900 x_tenant_id_header(),
901 package_id_param(),
902 kv_namespace_param(),
903 kv_key_param(),
904 ]))
905 .request_body(Some(
906 RequestBodyBuilder::new()
907 .description(Some("JSON value (string, number, object, or array)"))
908 .content(
909 "application/json",
910 Content::new(Some(RefOr::T(json_object_schema()))),
911 )
912 .required(Some(Required::True))
913 .build(),
914 ))
915 .responses(
916 ResponsesBuilder::new()
917 .response("200", Response::new("OK"))
918 .response("400", Response::new("Bad Request"))
919 .build(),
920 )
921 .build();
922
923 let delete_op = OperationBuilder::new()
924 .summary(Some("Delete KV key"))
925 .description(Some("Delete key. Returns 204 No Content."))
926 .operation_id(Some("kv_delete"))
927 .parameters(Some(vec![
928 x_tenant_id_header(),
929 package_id_param(),
930 kv_namespace_param(),
931 kv_key_param(),
932 ]))
933 .responses(
934 ResponsesBuilder::new()
935 .response("204", Response::new("No Content"))
936 .response("404", Response::new("Not Found"))
937 .build(),
938 )
939 .build();
940
941 (get_op, put_op, delete_op)
942}
943
944fn add_kv_paths(
946 mut builder: PathsBuilder,
947 base: &str,
948 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
949) -> PathsBuilder {
950 for (package_id, stores) in package_kv_stores {
951 if stores.is_empty() {
952 continue;
953 }
954 let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
955 let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
956
957 let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
958 builder = builder.path(list_path, list_item.build());
959
960 let (get_op, put_op, delete_op) = kv_key_operations();
961 let key_item = PathItemBuilder::new()
962 .operation(HttpMethod::Get, get_op)
963 .operation(HttpMethod::Put, put_op)
964 .operation(HttpMethod::Delete, delete_op);
965 builder = builder.path(key_path, key_item.build());
966 }
967 builder
968}
969
970fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
972 let install_path = format!("{}/config/package", base);
973 let install_op = OperationBuilder::new()
974 .summary(Some("Install package"))
975 .description(Some(
976 "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).",
977 ))
978 .operation_id(Some("config_install_package"))
979 .parameters(Some(vec![x_tenant_id_header()]))
980 .request_body(Some(
981 RequestBodyBuilder::new()
982 .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
983 .content(
984 "multipart/form-data",
985 Content::new(Some(RefOr::T(Schema::Object(
986 ObjectBuilder::new()
987 .schema_type(SchemaType::new(Type::Object))
988 .property(
989 "file",
990 Schema::Object(
991 ObjectBuilder::new()
992 .schema_type(SchemaType::new(Type::String))
993 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
994 utoipa::openapi::schema::KnownFormat::Binary,
995 )))
996 .description(Some("ZIP file (manifest.json + config JSONs)"))
997 .into(),
998 ),
999 )
1000 .into(),
1001 )))),
1002 )
1003 .required(Some(Required::True))
1004 .build(),
1005 ))
1006 .responses(
1007 ResponsesBuilder::new()
1008 .response("200", Response::new("OK"))
1009 .response("400", Response::new("Bad Request"))
1010 .build(),
1011 )
1012 .build();
1013 let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
1014 builder = builder.path(install_path, install_item.build());
1015
1016 let uninstall_path = format!("{}/config/package/{{packageId}}", base);
1017 let uninstall_op = OperationBuilder::new()
1018 .summary(Some("Uninstall package"))
1019 .description(Some(
1020 "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
1021 ))
1022 .operation_id(Some("config_uninstall_package"))
1023 .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
1024 .responses(
1025 ResponsesBuilder::new()
1026 .response("200", Response::new("OK"))
1027 .response("404", Response::new("Not Found"))
1028 .build(),
1029 )
1030 .build();
1031 let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
1032 builder = builder.path(uninstall_path, uninstall_item.build());
1033
1034 let config_kinds = [
1035 ("schemas", "Schema definitions"),
1036 ("enums", "Enum types"),
1037 ("tables", "Table definitions"),
1038 ("columns", "Column definitions"),
1039 ("indexes", "Index definitions"),
1040 ("relationships", "Relationship definitions"),
1041 ("api_entities", "API entity definitions"),
1042 ("kv_stores", "KV store definitions"),
1043 ];
1044 for (kind, description) in config_kinds {
1045 let path = format!("{}/config/{}", base, kind);
1046 let get_op = OperationBuilder::new()
1047 .summary(Some(format!("Get {}", kind)))
1048 .description(Some(format!(
1049 "Get {} (from _sys_{}). {}",
1050 description, kind, "X-Tenant-ID required."
1051 )))
1052 .operation_id(Some(format!("config_get_{}", kind)))
1053 .parameters(Some(vec![x_tenant_id_header()]))
1054 .responses(default_responses().build())
1055 .build();
1056 let post_body = RequestBodyBuilder::new()
1057 .description(Some(format!("JSON array of {} records.", description)))
1058 .content(
1059 "application/json",
1060 Content::new(Some(RefOr::T(Schema::Array(
1061 utoipa::openapi::schema::ArrayBuilder::new()
1062 .items(RefOr::T(json_object_schema()))
1063 .into(),
1064 )))),
1065 )
1066 .required(Some(Required::True))
1067 .build();
1068 let post_op = OperationBuilder::new()
1069 .summary(Some(format!("Replace {}", kind)))
1070 .description(Some(format!(
1071 "Replace {} for the default package. Runs migrations when rows change.",
1072 kind
1073 )))
1074 .operation_id(Some(format!("config_post_{}", kind)))
1075 .parameters(Some(vec![x_tenant_id_header()]))
1076 .request_body(Some(post_body))
1077 .responses(default_responses().build())
1078 .build();
1079 let item = PathItemBuilder::new()
1080 .operation(HttpMethod::Get, get_op)
1081 .operation(HttpMethod::Post, post_op);
1082 builder = builder.path(path, item.build());
1083 }
1084 builder
1085}
1086
1087pub fn build_spec(
1090 default_model: &ResolvedModel,
1091 base_path: &str,
1092 package_models: &HashMap<String, ResolvedModel>,
1093 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1094) -> OpenApi {
1095 let server = build_server();
1096 let mut builder = PathsBuilder::new();
1097 builder = add_config_paths(builder, base_path);
1098 builder = add_entity_paths(builder, base_path, default_model, false, None);
1099 for (package_id, model) in package_models {
1100 if !model.entities.is_empty() {
1101 builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
1102 }
1103 }
1104 builder = add_kv_paths(builder, base_path, package_kv_stores);
1105 let paths = builder.build();
1106 OpenApiBuilder::new()
1107 .info(
1108 Info::builder()
1109 .title("Architect API")
1110 .version(env!("CARGO_PKG_VERSION"))
1111 .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
1112 .build(),
1113 )
1114 .servers(Some(vec![server]))
1115 .paths(paths)
1116 .build()
1117}
1118
1119pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
1123 let default_model = state.model.read().expect("model read lock").clone();
1124 let base_path = "/api/v1";
1125
1126 let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
1127 let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
1128 let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
1129 for package_id in package_ids {
1130 if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
1131 if let Ok(model) = resolve(&config) {
1132 package_models.insert(package_id.clone(), model);
1133 }
1134 package_kv_stores.insert(package_id, config.kv_stores);
1135 }
1136 }
1137
1138 let spec = build_spec(
1139 &default_model,
1140 base_path,
1141 &package_models,
1142 &package_kv_stores,
1143 );
1144 Json(spec)
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use super::*;
1150 use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
1151 use std::collections::{HashMap, HashSet};
1152
1153 fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
1154 ResolvedEntity {
1155 table_id: seg.to_string(),
1156 schema_name: "public".into(),
1157 table_name: seg.to_string(),
1158 path_segment: seg.to_string(),
1159 pk_columns: vec!["id".into()],
1160 pk_type: PkType::Uuid,
1161 columns: vec![],
1162 operations: vec![
1163 "read".into(),
1164 "create".into(),
1165 "update".into(),
1166 "delete".into(),
1167 ],
1168 sensitive_columns: HashSet::new(),
1169 includes: vec![],
1170 validation: HashMap::new(),
1171 events: vec![],
1172 archive_field: None,
1173 package_id: "_default".into(),
1174 audit_log: false,
1175 global: false,
1176 parent_ref_column: None,
1177 versioning: None,
1178 mcp: None,
1179 extensible_columns,
1180 }
1181 }
1182
1183 #[test]
1184 fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
1185 let model = ResolvedModel {
1186 entities: vec![
1187 entity("products", vec!["attributes".into()]),
1188 entity("orders", vec![]),
1189 ],
1190 entity_by_path: HashMap::new(),
1191 };
1192 let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
1193 let json = serde_json::to_string(&spec).expect("serialize spec");
1194
1195 assert!(json.contains("/api/v1/products/extensible-fields"));
1197 assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
1198 assert!(!json.contains("/api/v1/orders/extensible-fields"));
1200 }
1201
1202 #[test]
1203 fn spec_lists_package_scoped_extensible_field_paths() {
1204 let default_model = ResolvedModel {
1205 entities: vec![entity("products", vec!["attributes".into()])],
1206 entity_by_path: HashMap::new(),
1207 };
1208 let mut package_models = HashMap::new();
1209 package_models.insert(
1210 "billing".to_string(),
1211 ResolvedModel {
1212 entities: vec![entity("invoices", vec!["meta".into()])],
1213 entity_by_path: HashMap::new(),
1214 },
1215 );
1216 let spec = build_spec(&default_model, "/api/v1", &package_models, &HashMap::new());
1217 let json = serde_json::to_string(&spec).expect("serialize spec");
1218
1219 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields"));
1221 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields/indexes"));
1222 }
1223}