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