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