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 builder
596}
597
598fn kv_namespace_param() -> Parameter {
599 ParameterBuilder::new()
600 .name("namespace")
601 .parameter_in(ParameterIn::Path)
602 .required(Required::True)
603 .description(Some("KV store namespace (from _sys_kv_stores)."))
604 .schema(Some(RefOr::T(Schema::Object(
605 utoipa::openapi::schema::ObjectBuilder::new()
606 .schema_type(SchemaType::new(Type::String))
607 .into(),
608 ))))
609 .build()
610}
611
612fn kv_list_keys_operation() -> Operation {
613 OperationBuilder::new()
614 .summary(Some("List KV keys in namespace"))
615 .description(Some(
616 "List all keys and values in the given package and namespace.",
617 ))
618 .operation_id(Some("kv_list_keys"))
619 .parameters(Some(vec![
620 x_tenant_id_header(),
621 package_id_param(),
622 kv_namespace_param(),
623 ]))
624 .responses(default_responses().build())
625 .build()
626}
627
628fn kv_key_param() -> Parameter {
629 ParameterBuilder::new()
630 .name("key")
631 .parameter_in(ParameterIn::Path)
632 .required(Required::True)
633 .description(Some("KV key"))
634 .schema(Some(RefOr::T(Schema::Object(
635 utoipa::openapi::schema::ObjectBuilder::new()
636 .schema_type(SchemaType::new(Type::String))
637 .into(),
638 ))))
639 .build()
640}
641
642fn kv_key_operations() -> (Operation, Operation, Operation) {
643 let get_op = OperationBuilder::new()
644 .summary(Some("Get KV value by key"))
645 .description(Some("Get value for key in package and namespace."))
646 .operation_id(Some("kv_get"))
647 .parameters(Some(vec![
648 x_tenant_id_header(),
649 package_id_param(),
650 kv_namespace_param(),
651 kv_key_param(),
652 ]))
653 .responses(default_responses().build())
654 .build();
655
656 let put_op = OperationBuilder::new()
657 .summary(Some("Set KV value (upsert)"))
658 .description(Some(
659 "Set or overwrite value for key. Body is arbitrary JSON.",
660 ))
661 .operation_id(Some("kv_put"))
662 .parameters(Some(vec![
663 x_tenant_id_header(),
664 package_id_param(),
665 kv_namespace_param(),
666 kv_key_param(),
667 ]))
668 .request_body(Some(
669 RequestBodyBuilder::new()
670 .description(Some("JSON value (string, number, object, or array)"))
671 .content(
672 "application/json",
673 Content::new(Some(RefOr::T(json_object_schema()))),
674 )
675 .required(Some(Required::True))
676 .build(),
677 ))
678 .responses(
679 ResponsesBuilder::new()
680 .response("200", Response::new("OK"))
681 .response("400", Response::new("Bad Request"))
682 .build(),
683 )
684 .build();
685
686 let delete_op = OperationBuilder::new()
687 .summary(Some("Delete KV key"))
688 .description(Some("Delete key. Returns 204 No Content."))
689 .operation_id(Some("kv_delete"))
690 .parameters(Some(vec![
691 x_tenant_id_header(),
692 package_id_param(),
693 kv_namespace_param(),
694 kv_key_param(),
695 ]))
696 .responses(
697 ResponsesBuilder::new()
698 .response("204", Response::new("No Content"))
699 .response("404", Response::new("Not Found"))
700 .build(),
701 )
702 .build();
703
704 (get_op, put_op, delete_op)
705}
706
707fn add_kv_paths(
709 mut builder: PathsBuilder,
710 base: &str,
711 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
712) -> PathsBuilder {
713 for (package_id, stores) in package_kv_stores {
714 if stores.is_empty() {
715 continue;
716 }
717 let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
718 let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
719
720 let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
721 builder = builder.path(list_path, list_item.build());
722
723 let (get_op, put_op, delete_op) = kv_key_operations();
724 let key_item = PathItemBuilder::new()
725 .operation(HttpMethod::Get, get_op)
726 .operation(HttpMethod::Put, put_op)
727 .operation(HttpMethod::Delete, delete_op);
728 builder = builder.path(key_path, key_item.build());
729 }
730 builder
731}
732
733fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
735 let install_path = format!("{}/config/package", base);
736 let install_op = OperationBuilder::new()
737 .summary(Some("Install package"))
738 .description(Some(
739 "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).",
740 ))
741 .operation_id(Some("config_install_package"))
742 .parameters(Some(vec![x_tenant_id_header()]))
743 .request_body(Some(
744 RequestBodyBuilder::new()
745 .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
746 .content(
747 "multipart/form-data",
748 Content::new(Some(RefOr::T(Schema::Object(
749 ObjectBuilder::new()
750 .schema_type(SchemaType::new(Type::Object))
751 .property(
752 "file",
753 Schema::Object(
754 ObjectBuilder::new()
755 .schema_type(SchemaType::new(Type::String))
756 .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
757 utoipa::openapi::schema::KnownFormat::Binary,
758 )))
759 .description(Some("ZIP file (manifest.json + config JSONs)"))
760 .into(),
761 ),
762 )
763 .into(),
764 )))),
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 let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
777 builder = builder.path(install_path, install_item.build());
778
779 let uninstall_path = format!("{}/config/package/{{packageId}}", base);
780 let uninstall_op = OperationBuilder::new()
781 .summary(Some("Uninstall package"))
782 .description(Some(
783 "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
784 ))
785 .operation_id(Some("config_uninstall_package"))
786 .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
787 .responses(
788 ResponsesBuilder::new()
789 .response("200", Response::new("OK"))
790 .response("404", Response::new("Not Found"))
791 .build(),
792 )
793 .build();
794 let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
795 builder = builder.path(uninstall_path, uninstall_item.build());
796
797 let config_kinds = [
798 ("schemas", "Schema definitions"),
799 ("enums", "Enum types"),
800 ("tables", "Table definitions"),
801 ("columns", "Column definitions"),
802 ("indexes", "Index definitions"),
803 ("relationships", "Relationship definitions"),
804 ("api_entities", "API entity definitions"),
805 ("kv_stores", "KV store definitions"),
806 ];
807 for (kind, description) in config_kinds {
808 let path = format!("{}/config/{}", base, kind);
809 let get_op = OperationBuilder::new()
810 .summary(Some(format!("Get {}", kind)))
811 .description(Some(format!(
812 "Get {} (from _sys_{}). {}",
813 description, kind, "X-Tenant-ID required."
814 )))
815 .operation_id(Some(format!("config_get_{}", kind)))
816 .parameters(Some(vec![x_tenant_id_header()]))
817 .responses(default_responses().build())
818 .build();
819 let post_body = RequestBodyBuilder::new()
820 .description(Some(format!("JSON array of {} records.", description)))
821 .content(
822 "application/json",
823 Content::new(Some(RefOr::T(Schema::Array(
824 utoipa::openapi::schema::ArrayBuilder::new()
825 .items(RefOr::T(json_object_schema()))
826 .into(),
827 )))),
828 )
829 .required(Some(Required::True))
830 .build();
831 let post_op = OperationBuilder::new()
832 .summary(Some(format!("Replace {}", kind)))
833 .description(Some(format!(
834 "Replace {} for the default package. Runs migrations when rows change.",
835 kind
836 )))
837 .operation_id(Some(format!("config_post_{}", kind)))
838 .parameters(Some(vec![x_tenant_id_header()]))
839 .request_body(Some(post_body))
840 .responses(default_responses().build())
841 .build();
842 let item = PathItemBuilder::new()
843 .operation(HttpMethod::Get, get_op)
844 .operation(HttpMethod::Post, post_op);
845 builder = builder.path(path, item.build());
846 }
847 builder
848}
849
850pub fn build_spec(
853 default_model: &ResolvedModel,
854 base_path: &str,
855 package_models: &HashMap<String, ResolvedModel>,
856 package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
857) -> OpenApi {
858 let server = build_server();
859 let mut builder = PathsBuilder::new();
860 builder = add_config_paths(builder, base_path);
861 builder = add_entity_paths(builder, base_path, default_model, false, None);
862 for (package_id, model) in package_models {
863 if !model.entities.is_empty() {
864 builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
865 }
866 }
867 builder = add_kv_paths(builder, base_path, package_kv_stores);
868 let paths = builder.build();
869 OpenApiBuilder::new()
870 .info(
871 Info::builder()
872 .title("Architect API")
873 .version(env!("CARGO_PKG_VERSION"))
874 .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
875 .build(),
876 )
877 .servers(Some(vec![server]))
878 .paths(paths)
879 .build()
880}
881
882pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
886 let default_model = state.model.read().expect("model read lock").clone();
887 let base_path = "/api/v1";
888
889 let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
890 let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
891 let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
892 for package_id in package_ids {
893 if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
894 if let Ok(model) = resolve(&config) {
895 package_models.insert(package_id.clone(), model);
896 }
897 package_kv_stores.insert(package_id, config.kv_stores);
898 }
899 }
900
901 let spec = build_spec(
902 &default_model,
903 base_path,
904 &package_models,
905 &package_kv_stores,
906 );
907 Json(spec)
908}