Skip to main content

architect_sdk/
openapi.rs

1//! Build OpenAPI spec from architect._sys_* tables. Exposed at GET /spec.
2//! APIs and paths come from _sys_api_entities per package; parameters and request/response body
3//! schemas are built from _sys_columns (column names, types, nullable, default). Entity and KV
4//! paths are generated dynamically by listing _sys_packages and loading each package's config.
5
6use 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
23/// Build server with URL `http://{host}:{port}` and variable defaults.
24fn 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
53/// Map PostgreSQL type (from _sys_columns) to OpenAPI schema type for parameters and body properties.
54fn column_schema_from_pg_type(pg_type: Option<&str>) -> Schema {
55    let t = pg_type.unwrap_or("").to_lowercase();
56    // Handle PostgreSQL array types (e.g. uuid[], text[], _int4, _uuid) by mapping
57    // them to OpenAPI arrays whose item schema is derived from the element type.
58    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
120/// Build OpenAPI object schema from entity columns (_sys_columns). Properties use camelCase.
121/// For create: required = !nullable && !has_default. For update: all optional (partial).
122fn 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
156/// X-Tenant-ID header required for all config and entity APIs.
157fn 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
173/// Path parameter for package-scoped routes: packageId (from architect._sys_packages). No literal package ids in the spec.
174fn 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
505/// Add entity paths for one model.
506/// - For default model: paths are `{base}/{path_segment}` (no package segment).
507/// - For package models: paths are `{base}/package/{package_id}/{path_segment}` with the concrete package id.
508fn 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
707/// Add KV store paths with concrete package ids and {namespace}/{key}.
708fn 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
733/// Add config API paths: install/uninstall package and GET/POST per config kind.
734fn 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
850/// Build full OpenAPI spec for entity APIs: default model paths plus package-scoped paths
851/// with concrete package ids, plus KV paths with {namespace}/{key} per package.
852pub 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
882/// GET /spec — return OpenAPI JSON for entity APIs. Default (unprefixed) routes come from
883/// state.model; package-scoped routes are built by listing _sys_packages and loading each
884/// package's config from _sys_* tables (same source of truth as runtime routes).
885pub 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}