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 !entity.extensible_columns.is_empty() {
598 let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity, op_suffix);
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, op_suffix);
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(
622 entity: &ResolvedEntity,
623 op_suffix: &str,
624) -> (Operation, Operation, Operation) {
625 let seg = &entity.path_segment;
626 let get = OperationBuilder::new()
627 .summary(Some("Get extensible-field registry"))
628 .description(Some(
629 "Return the tenant's extensible-field registry document for this entity (or {} when unset).",
630 ))
631 .operation_id(Some(format!("get_extensible_fields_{}{}", seg, op_suffix)))
632 .parameters(Some(vec![x_tenant_id_header()]))
633 .responses(default_responses().build())
634 .build();
635 let put = OperationBuilder::new()
636 .summary(Some("Replace extensible-field registry"))
637 .description(Some(
638 "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}]}.",
639 ))
640 .operation_id(Some(format!("put_extensible_fields_{}{}", seg, op_suffix)))
641 .parameters(Some(vec![x_tenant_id_header()]))
642 .request_body(Some(
643 RequestBodyBuilder::new()
644 .description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
645 .content(
646 "application/json",
647 Content::new(Some(RefOr::T(Schema::Object(
648 ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
649 )))),
650 )
651 .required(Some(Required::True))
652 .build(),
653 ))
654 .responses(default_responses().build())
655 .build();
656 let delete = OperationBuilder::new()
657 .summary(Some("Clear extensible-field registry"))
658 .description(Some(
659 "Delete the tenant's registry document for this entity.",
660 ))
661 .operation_id(Some(format!(
662 "delete_extensible_fields_{}{}",
663 seg, op_suffix
664 )))
665 .parameters(Some(vec![x_tenant_id_header()]))
666 .responses(default_responses().build())
667 .build();
668 (get, put, delete)
669}
670
671fn extensible_indexes_operations(
673 entity: &ResolvedEntity,
674 op_suffix: &str,
675) -> (Operation, Operation) {
676 let seg = &entity.path_segment;
677 let get = OperationBuilder::new()
678 .summary(Some("Suggested indexes for extensible fields"))
679 .description(Some(
680 "Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
681 ))
682 .operation_id(Some(format!("get_extensible_field_indexes_{}{}", seg, op_suffix)))
683 .parameters(Some(vec![x_tenant_id_header()]))
684 .responses(default_responses().build())
685 .build();
686 let post = OperationBuilder::new()
687 .summary(Some("Apply extensible-field indexes"))
688 .description(Some(
689 "Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
690 ))
691 .operation_id(Some(format!("apply_extensible_field_indexes_{}{}", seg, op_suffix)))
692 .parameters(Some(vec![x_tenant_id_header()]))
693 .responses(default_responses().build())
694 .build();
695 (get, post)
696}
697
698fn kv_namespace_param() -> Parameter {
699 ParameterBuilder::new()
700 .name("namespace")
701 .parameter_in(ParameterIn::Path)
702 .required(Required::True)
703 .description(Some("KV store namespace (from _sys_kv_stores)."))
704 .schema(Some(RefOr::T(Schema::Object(
705 utoipa::openapi::schema::ObjectBuilder::new()
706 .schema_type(SchemaType::new(Type::String))
707 .into(),
708 ))))
709 .build()
710}
711
712fn kv_list_keys_operation() -> Operation {
713 OperationBuilder::new()
714 .summary(Some("List KV keys in namespace"))
715 .description(Some(
716 "List all keys and values in the given package and namespace.",
717 ))
718 .operation_id(Some("kv_list_keys"))
719 .parameters(Some(vec![
720 x_tenant_id_header(),
721 package_id_param(),
722 kv_namespace_param(),
723 ]))
724 .responses(default_responses().build())
725 .build()
726}
727
728fn kv_key_param() -> Parameter {
729 ParameterBuilder::new()
730 .name("key")
731 .parameter_in(ParameterIn::Path)
732 .required(Required::True)
733 .description(Some("KV key"))
734 .schema(Some(RefOr::T(Schema::Object(
735 utoipa::openapi::schema::ObjectBuilder::new()
736 .schema_type(SchemaType::new(Type::String))
737 .into(),
738 ))))
739 .build()
740}
741
742fn kv_key_operations() -> (Operation, Operation, Operation) {
743 let get_op = OperationBuilder::new()
744 .summary(Some("Get KV value by key"))
745 .description(Some("Get value for key in package and namespace."))
746 .operation_id(Some("kv_get"))
747 .parameters(Some(vec![
748 x_tenant_id_header(),
749 package_id_param(),
750 kv_namespace_param(),
751 kv_key_param(),
752 ]))
753 .responses(default_responses().build())
754 .build();
755
756 let put_op = OperationBuilder::new()
757 .summary(Some("Set KV value (upsert)"))
758 .description(Some(
759 "Set or overwrite value for key. Body is arbitrary JSON.",
760 ))
761 .operation_id(Some("kv_put"))
762 .parameters(Some(vec![
763 x_tenant_id_header(),
764 package_id_param(),
765 kv_namespace_param(),
766 kv_key_param(),
767 ]))
768 .request_body(Some(
769 RequestBodyBuilder::new()
770 .description(Some("JSON value (string, number, object, or array)"))
771 .content(
772 "application/json",
773 Content::new(Some(RefOr::T(json_object_schema()))),
774 )
775 .required(Some(Required::True))
776 .build(),
777 ))
778 .responses(
779 ResponsesBuilder::new()
780 .response("200", Response::new("OK"))
781 .response("400", Response::new("Bad Request"))
782 .build(),
783 )
784 .build();
785
786 let delete_op = OperationBuilder::new()
787 .summary(Some("Delete KV key"))
788 .description(Some("Delete key. Returns 204 No Content."))
789 .operation_id(Some("kv_delete"))
790 .parameters(Some(vec![
791 x_tenant_id_header(),
792 package_id_param(),
793 kv_namespace_param(),
794 kv_key_param(),
795 ]))
796 .responses(
797 ResponsesBuilder::new()
798 .response("204", Response::new("No Content"))
799 .response("404", Response::new("Not Found"))
800 .build(),
801 )
802 .build();
803
804 (get_op, put_op, delete_op)
805}
806
807fn add_kv_paths(
809 mut builder: PathsBuilder,
810 base: &str,
811 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
812) -> PathsBuilder {
813 for (package_id, stores) in package_kv_stores {
814 if stores.is_empty() {
815 continue;
816 }
817 let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
818 let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
819
820 let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
821 builder = builder.path(list_path, list_item.build());
822
823 let (get_op, put_op, delete_op) = kv_key_operations();
824 let key_item = PathItemBuilder::new()
825 .operation(HttpMethod::Get, get_op)
826 .operation(HttpMethod::Put, put_op)
827 .operation(HttpMethod::Delete, delete_op);
828 builder = builder.path(key_path, key_item.build());
829 }
830 builder
831}
832
833fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
835 let install_path = format!("{}/config/package", base);
836 let install_op = OperationBuilder::new()
837 .summary(Some("Install package"))
838 .description(Some(
839 "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).",
840 ))
841 .operation_id(Some("config_install_package"))
842 .parameters(Some(vec![x_tenant_id_header()]))
843 .request_body(Some(
844 RequestBodyBuilder::new()
845 .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
846 .content(
847 "multipart/form-data",
848 Content::new(Some(RefOr::T(Schema::Object(
849 ObjectBuilder::new()
850 .schema_type(SchemaType::new(Type::Object))
851 .property(
852 "file",
853 Schema::Object(
854 ObjectBuilder::new()
855 .schema_type(SchemaType::new(Type::String))
856 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
857 utoipa::openapi::schema::KnownFormat::Binary,
858 )))
859 .description(Some("ZIP file (manifest.json + config JSONs)"))
860 .into(),
861 ),
862 )
863 .into(),
864 )))),
865 )
866 .required(Some(Required::True))
867 .build(),
868 ))
869 .responses(
870 ResponsesBuilder::new()
871 .response("200", Response::new("OK"))
872 .response("400", Response::new("Bad Request"))
873 .build(),
874 )
875 .build();
876 let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
877 builder = builder.path(install_path, install_item.build());
878
879 let uninstall_path = format!("{}/config/package/{{packageId}}", base);
880 let uninstall_op = OperationBuilder::new()
881 .summary(Some("Uninstall package"))
882 .description(Some(
883 "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
884 ))
885 .operation_id(Some("config_uninstall_package"))
886 .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
887 .responses(
888 ResponsesBuilder::new()
889 .response("200", Response::new("OK"))
890 .response("404", Response::new("Not Found"))
891 .build(),
892 )
893 .build();
894 let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
895 builder = builder.path(uninstall_path, uninstall_item.build());
896
897 let config_kinds = [
898 ("schemas", "Schema definitions"),
899 ("enums", "Enum types"),
900 ("tables", "Table definitions"),
901 ("columns", "Column definitions"),
902 ("indexes", "Index definitions"),
903 ("relationships", "Relationship definitions"),
904 ("api_entities", "API entity definitions"),
905 ("kv_stores", "KV store definitions"),
906 ];
907 for (kind, description) in config_kinds {
908 let path = format!("{}/config/{}", base, kind);
909 let get_op = OperationBuilder::new()
910 .summary(Some(format!("Get {}", kind)))
911 .description(Some(format!(
912 "Get {} (from _sys_{}). {}",
913 description, kind, "X-Tenant-ID required."
914 )))
915 .operation_id(Some(format!("config_get_{}", kind)))
916 .parameters(Some(vec![x_tenant_id_header()]))
917 .responses(default_responses().build())
918 .build();
919 let post_body = RequestBodyBuilder::new()
920 .description(Some(format!("JSON array of {} records.", description)))
921 .content(
922 "application/json",
923 Content::new(Some(RefOr::T(Schema::Array(
924 utoipa::openapi::schema::ArrayBuilder::new()
925 .items(RefOr::T(json_object_schema()))
926 .into(),
927 )))),
928 )
929 .required(Some(Required::True))
930 .build();
931 let post_op = OperationBuilder::new()
932 .summary(Some(format!("Replace {}", kind)))
933 .description(Some(format!(
934 "Replace {} for the default package. Runs migrations when rows change.",
935 kind
936 )))
937 .operation_id(Some(format!("config_post_{}", kind)))
938 .parameters(Some(vec![x_tenant_id_header()]))
939 .request_body(Some(post_body))
940 .responses(default_responses().build())
941 .build();
942 let item = PathItemBuilder::new()
943 .operation(HttpMethod::Get, get_op)
944 .operation(HttpMethod::Post, post_op);
945 builder = builder.path(path, item.build());
946 }
947 builder
948}
949
950pub fn build_spec(
953 default_model: &ResolvedModel,
954 base_path: &str,
955 package_models: &HashMap<String, ResolvedModel>,
956 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
957) -> OpenApi {
958 let server = build_server();
959 let mut builder = PathsBuilder::new();
960 builder = add_config_paths(builder, base_path);
961 builder = add_entity_paths(builder, base_path, default_model, false, None);
962 for (package_id, model) in package_models {
963 if !model.entities.is_empty() {
964 builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
965 }
966 }
967 builder = add_kv_paths(builder, base_path, package_kv_stores);
968 let paths = builder.build();
969 OpenApiBuilder::new()
970 .info(
971 Info::builder()
972 .title("Architect API")
973 .version(env!("CARGO_PKG_VERSION"))
974 .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
975 .build(),
976 )
977 .servers(Some(vec![server]))
978 .paths(paths)
979 .build()
980}
981
982pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
986 let default_model = state.model.read().expect("model read lock").clone();
987 let base_path = "/api/v1";
988
989 let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
990 let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
991 let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
992 for package_id in package_ids {
993 if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
994 if let Ok(model) = resolve(&config) {
995 package_models.insert(package_id.clone(), model);
996 }
997 package_kv_stores.insert(package_id, config.kv_stores);
998 }
999 }
1000
1001 let spec = build_spec(
1002 &default_model,
1003 base_path,
1004 &package_models,
1005 &package_kv_stores,
1006 );
1007 Json(spec)
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
1014 use std::collections::{HashMap, HashSet};
1015
1016 fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
1017 ResolvedEntity {
1018 table_id: seg.to_string(),
1019 schema_name: "public".into(),
1020 table_name: seg.to_string(),
1021 path_segment: seg.to_string(),
1022 pk_columns: vec!["id".into()],
1023 pk_type: PkType::Uuid,
1024 columns: vec![],
1025 operations: vec![
1026 "read".into(),
1027 "create".into(),
1028 "update".into(),
1029 "delete".into(),
1030 ],
1031 sensitive_columns: HashSet::new(),
1032 includes: vec![],
1033 validation: HashMap::new(),
1034 events: vec![],
1035 archive_field: None,
1036 package_id: "_default".into(),
1037 audit_log: false,
1038 parent_ref_column: None,
1039 versioning: None,
1040 mcp: None,
1041 extensible_columns,
1042 }
1043 }
1044
1045 #[test]
1046 fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
1047 let model = ResolvedModel {
1048 entities: vec![
1049 entity("products", vec!["attributes".into()]),
1050 entity("orders", vec![]),
1051 ],
1052 entity_by_path: HashMap::new(),
1053 };
1054 let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
1055 let json = serde_json::to_string(&spec).expect("serialize spec");
1056
1057 assert!(json.contains("/api/v1/products/extensible-fields"));
1059 assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
1060 assert!(!json.contains("/api/v1/orders/extensible-fields"));
1062 }
1063
1064 #[test]
1065 fn spec_lists_package_scoped_extensible_field_paths() {
1066 let default_model = ResolvedModel {
1067 entities: vec![entity("products", vec!["attributes".into()])],
1068 entity_by_path: HashMap::new(),
1069 };
1070 let mut package_models = HashMap::new();
1071 package_models.insert(
1072 "billing".to_string(),
1073 ResolvedModel {
1074 entities: vec![entity("invoices", vec!["meta".into()])],
1075 entity_by_path: HashMap::new(),
1076 },
1077 );
1078 let spec = build_spec(&default_model, "/api/v1", &package_models, &HashMap::new());
1079 let json = serde_json::to_string(&spec).expect("serialize spec");
1080
1081 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields"));
1083 assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields/indexes"));
1084 }
1085}