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