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::{
8    load_from_pool, resolve, IncludeDirection, KvStoreConfig, ResolvedEntity, ResolvedModel,
9};
10use crate::state::AppState;
11use crate::store::list_package_ids;
12use axum::extract::State;
13use axum::Json;
14use std::collections::HashMap;
15use utoipa::openapi::path::{
16    HttpMethod, Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn,
17    PathItemBuilder, PathsBuilder,
18};
19use utoipa::openapi::request_body::RequestBodyBuilder;
20use utoipa::openapi::response::{Response, ResponsesBuilder};
21use utoipa::openapi::schema::{
22    ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, SchemaType, Type,
23};
24use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
25use utoipa::openapi::{Content, Info, OpenApi, OpenApiBuilder, RefOr, Required};
26
27/// Build server with URL `http://{host}:{port}` and variable defaults.
28fn build_server() -> utoipa::openapi::server::Server {
29    ServerBuilder::new()
30        .url("http://{host}:{port}")
31        .parameter(
32            "host",
33            ServerVariableBuilder::new()
34                .default_value("localhost")
35                .description(Some("API host")),
36        )
37        .parameter(
38            "port",
39            ServerVariableBuilder::new()
40                .default_value("3000")
41                .description(Some("API port")),
42        )
43        .build()
44}
45
46fn json_object_schema() -> Schema {
47    Schema::Object(
48        ObjectBuilder::new()
49            .schema_type(SchemaType::new(Type::Object))
50            .description(Some(
51                "JSON object; keys may be in camelCase (e.g. entity fields).",
52            ))
53            .into(),
54    )
55}
56
57/// Map PostgreSQL type (from _sys_columns) to OpenAPI schema type for parameters and body properties.
58fn column_schema_from_pg_type(pg_type: Option<&str>) -> Schema {
59    let t = pg_type.unwrap_or("").to_lowercase();
60    // Handle PostgreSQL array types (e.g. uuid[], text[], _int4, _uuid) by mapping
61    // them to OpenAPI arrays whose item schema is derived from the element type.
62    if t.ends_with("[]") || t.starts_with('_') {
63        let element_type = t.trim_end_matches("[]").trim_start_matches('_');
64        let item_schema = column_schema_from_pg_type(Some(element_type));
65        return Schema::Array(
66            utoipa::openapi::schema::ArrayBuilder::new()
67                .items(RefOr::T(item_schema))
68                .build(),
69        );
70    }
71    if t.contains("int") || t.contains("serial") {
72        return Schema::Object(
73            utoipa::openapi::schema::ObjectBuilder::new()
74                .schema_type(SchemaType::new(Type::Integer))
75                .into(),
76        );
77    }
78    if t.contains("bool") {
79        return Schema::Object(
80            utoipa::openapi::schema::ObjectBuilder::new()
81                .schema_type(SchemaType::new(Type::Boolean))
82                .into(),
83        );
84    }
85    if t.contains("uuid") {
86        return Schema::Object(
87            utoipa::openapi::schema::ObjectBuilder::new()
88                .schema_type(SchemaType::new(Type::String))
89                .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
90                    utoipa::openapi::schema::KnownFormat::Uuid,
91                )))
92                .into(),
93        );
94    }
95    if t.contains("numeric")
96        || t.contains("decimal")
97        || t.contains("real")
98        || t.contains("double")
99        || t.contains("float")
100    {
101        return Schema::Object(
102            utoipa::openapi::schema::ObjectBuilder::new()
103                .schema_type(SchemaType::new(Type::Number))
104                .into(),
105        );
106    }
107    if t.contains("timestamp") || t.contains("date") {
108        return Schema::Object(
109            utoipa::openapi::schema::ObjectBuilder::new()
110                .schema_type(SchemaType::new(Type::String))
111                .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
112                    utoipa::openapi::schema::KnownFormat::DateTime,
113                )))
114                .into(),
115        );
116    }
117    Schema::Object(
118        utoipa::openapi::schema::ObjectBuilder::new()
119            .schema_type(SchemaType::new(Type::String))
120            .into(),
121    )
122}
123
124/// Build OpenAPI object schema from entity columns (_sys_columns). Properties use camelCase.
125/// For create: required = !nullable && !has_default. For update: all optional (partial).
126fn entity_body_schema(entity: &ResolvedEntity, for_create: bool) -> Schema {
127    let mut builder = utoipa::openapi::schema::ObjectBuilder::new()
128        .schema_type(SchemaType::new(Type::Object))
129        .description(Some(format!(
130            "Fields from architect._sys_columns for table {} (API uses camelCase).",
131            entity.table_id
132        )));
133    let mut required = Vec::new();
134    for col in &entity.columns {
135        if entity.sensitive_columns.contains(&col.name) {
136            continue;
137        }
138        let camel = to_camel_case(&col.name);
139        let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
140        builder = builder.property(camel.clone(), RefOr::T(prop_schema));
141        if for_create && !col.nullable && !col.has_default {
142            required.push(camel);
143        }
144    }
145    for r in &required {
146        builder = builder.required(r.clone());
147    }
148    Schema::Object(builder.into())
149}
150
151fn default_responses() -> ResponsesBuilder {
152    ResponsesBuilder::new()
153        .response("200", Response::new("OK"))
154        .response("201", Response::new("Created"))
155        .response("204", Response::new("No Content"))
156        .response("400", Response::new("Bad Request"))
157        .response("404", Response::new("Not Found"))
158}
159
160/// X-Tenant-ID header required for all config and entity APIs.
161fn x_tenant_id_header() -> Parameter {
162    ParameterBuilder::new()
163        .name("X-Tenant-ID")
164        .parameter_in(ParameterIn::Header)
165        .required(Required::True)
166        .description(Some(
167            "Tenant id; must match a tenant in architect._sys_tenants (e.g. default-mode-1, default-mode-3).",
168        ))
169        .schema(Some(RefOr::T(Schema::Object(
170            utoipa::openapi::schema::ObjectBuilder::new()
171                .schema_type(SchemaType::new(Type::String))
172                .into(),
173        ))))
174        .build()
175}
176
177/// Path parameter for package-scoped routes: packageId (from architect._sys_packages). No literal package ids in the spec.
178fn package_id_param() -> Parameter {
179    ParameterBuilder::new()
180        .name("packageId")
181        .parameter_in(ParameterIn::Path)
182        .required(Required::True)
183        .description(Some("Package id from architect._sys_packages."))
184        .schema(Some(RefOr::T(Schema::Object(
185            utoipa::openapi::schema::ObjectBuilder::new()
186                .schema_type(SchemaType::new(Type::String))
187                .into(),
188        ))))
189        .build()
190}
191
192fn list_operation(
193    entity: &ResolvedEntity,
194    op_suffix: &str,
195    include_package_id_param: bool,
196) -> Operation {
197    let mut params = vec![x_tenant_id_header()];
198    if include_package_id_param {
199        params.push(package_id_param());
200    }
201    params.extend(vec![
202        ParameterBuilder::new()
203            .name("limit")
204            .parameter_in(ParameterIn::Query)
205            .required(Required::False)
206            .description(Some("Max number of items to return"))
207            .schema(Some(RefOr::T(Schema::Object(
208                utoipa::openapi::schema::ObjectBuilder::new()
209                    .schema_type(SchemaType::new(Type::Integer))
210                    .into(),
211            ))))
212            .build(),
213        ParameterBuilder::new()
214            .name("offset")
215            .parameter_in(ParameterIn::Query)
216            .required(Required::False)
217            .description(Some("Number of items to skip"))
218            .schema(Some(RefOr::T(Schema::Object(
219                utoipa::openapi::schema::ObjectBuilder::new()
220                    .schema_type(SchemaType::new(Type::Integer))
221                    .into(),
222            ))))
223            .build(),
224        ParameterBuilder::new()
225            .name("include")
226            .parameter_in(ParameterIn::Query)
227            .required(Required::False)
228            .description(Some(
229                "Comma-separated related entity path segments to include",
230            ))
231            .schema(Some(RefOr::T(Schema::Object(
232                utoipa::openapi::schema::ObjectBuilder::new()
233                    .schema_type(SchemaType::new(Type::String))
234                    .into(),
235            ))))
236            .build(),
237    ]);
238    for col in &entity.columns {
239        if entity.sensitive_columns.contains(&col.name) {
240            continue;
241        }
242        let camel = to_camel_case(&col.name);
243        let schema = column_schema_from_pg_type(col.pg_type.as_deref());
244        params.push(
245            ParameterBuilder::new()
246                .name(camel)
247                .parameter_in(ParameterIn::Query)
248                .required(Required::False)
249                .description(Some(format!("Filter by {} (from _sys_columns)", col.name)))
250                .schema(Some(RefOr::T(schema)))
251                .build(),
252        );
253    }
254    OperationBuilder::new()
255        .summary(Some(format!("List {}", entity.path_segment)))
256        .description(Some(format!(
257            "List {} with optional filters, pagination (limit, offset), and includes.",
258            entity.path_segment
259        )))
260        .operation_id(Some(format!("list_{}{}", entity.path_segment, op_suffix)))
261        .parameters(Some(params))
262        .responses(default_responses().build())
263        .build()
264}
265
266fn create_operation(
267    entity: &ResolvedEntity,
268    op_suffix: &str,
269    include_package_id_param: bool,
270) -> Operation {
271    let mut params = vec![x_tenant_id_header()];
272    if include_package_id_param {
273        params.push(package_id_param());
274    }
275    let body = RequestBodyBuilder::new()
276        .description(Some(format!(
277            "JSON object with {} fields from _sys_columns (camelCase). PK may be omitted if DB default exists.",
278            entity.path_segment
279        )))
280        .content(
281            "application/json",
282            Content::new(Some(RefOr::T(entity_body_schema(entity, true)))),
283        )
284        .required(Some(Required::True))
285        .build();
286    OperationBuilder::new()
287        .summary(Some(format!("Create {}", entity.path_segment)))
288        .description(Some(format!("Create a single {}", entity.path_segment)))
289        .operation_id(Some(format!("create_{}{}", entity.path_segment, op_suffix)))
290        .parameters(Some(params))
291        .request_body(Some(body))
292        .responses(
293            ResponsesBuilder::new()
294                .response("201", Response::new("Created"))
295                .response("400", Response::new("Bad Request"))
296                .build(),
297        )
298        .build()
299}
300
301fn read_operation(
302    entity: &ResolvedEntity,
303    op_suffix: &str,
304    include_package_id_param: bool,
305) -> Operation {
306    let mut params = vec![x_tenant_id_header()];
307    if include_package_id_param {
308        params.push(package_id_param());
309    }
310    let id_param = ParameterBuilder::new()
311        .name("id")
312        .parameter_in(ParameterIn::Path)
313        .required(Required::True)
314        .description(Some(
315            "Entity ID (UUID, integer, or text depending on table PK)",
316        ))
317        .schema(Some(RefOr::T(Schema::Object(
318            utoipa::openapi::schema::ObjectBuilder::new()
319                .schema_type(SchemaType::new(Type::String))
320                .into(),
321        ))))
322        .build();
323    let include_param = ParameterBuilder::new()
324        .name("include")
325        .parameter_in(ParameterIn::Query)
326        .required(Required::False)
327        .description(Some(
328            "Comma-separated related entity path segments to include",
329        ))
330        .schema(Some(RefOr::T(Schema::Object(
331            utoipa::openapi::schema::ObjectBuilder::new()
332                .schema_type(SchemaType::new(Type::String))
333                .into(),
334        ))))
335        .build();
336    params.push(id_param);
337    params.push(include_param);
338    OperationBuilder::new()
339        .summary(Some(format!("Get {} by id", entity.path_segment)))
340        .description(Some(format!("Get a single {} by id.", entity.path_segment)))
341        .operation_id(Some(format!("read_{}{}", entity.path_segment, op_suffix)))
342        .parameters(Some(params))
343        .responses(default_responses().build())
344        .build()
345}
346
347fn update_operation(
348    entity: &ResolvedEntity,
349    op_suffix: &str,
350    include_package_id_param: bool,
351) -> Operation {
352    let mut params = vec![x_tenant_id_header()];
353    if include_package_id_param {
354        params.push(package_id_param());
355    }
356    let id_param = ParameterBuilder::new()
357        .name("id")
358        .parameter_in(ParameterIn::Path)
359        .required(Required::True)
360        .description(Some("Entity ID"))
361        .schema(Some(RefOr::T(Schema::Object(
362            utoipa::openapi::schema::ObjectBuilder::new()
363                .schema_type(SchemaType::new(Type::String))
364                .into(),
365        ))))
366        .build();
367    params.push(id_param);
368    let body = RequestBodyBuilder::new()
369        .description(Some(
370            "JSON object with fields from _sys_columns to update (camelCase, partial).",
371        ))
372        .content(
373            "application/json",
374            Content::new(Some(RefOr::T(entity_body_schema(entity, false)))),
375        )
376        .required(Some(Required::True))
377        .build();
378    OperationBuilder::new()
379        .summary(Some(format!("Update {} by id", entity.path_segment)))
380        .description(Some(format!(
381            "Update a single {} by id.",
382            entity.path_segment
383        )))
384        .operation_id(Some(format!("update_{}{}", entity.path_segment, op_suffix)))
385        .parameters(Some(params))
386        .request_body(Some(body))
387        .responses(default_responses().build())
388        .build()
389}
390
391fn delete_operation(
392    entity: &ResolvedEntity,
393    op_suffix: &str,
394    include_package_id_param: bool,
395) -> Operation {
396    let mut params = vec![x_tenant_id_header()];
397    if include_package_id_param {
398        params.push(package_id_param());
399    }
400    let id_param = ParameterBuilder::new()
401        .name("id")
402        .parameter_in(ParameterIn::Path)
403        .required(Required::True)
404        .description(Some("Entity ID"))
405        .schema(Some(RefOr::T(Schema::Object(
406            utoipa::openapi::schema::ObjectBuilder::new()
407                .schema_type(SchemaType::new(Type::String))
408                .into(),
409        ))))
410        .build();
411    params.push(id_param);
412    OperationBuilder::new()
413        .summary(Some(format!("Delete {} by id", entity.path_segment)))
414        .description(Some(format!(
415            "Delete a single {} by id.",
416            entity.path_segment
417        )))
418        .operation_id(Some(format!("delete_{}{}", entity.path_segment, op_suffix)))
419        .parameters(Some(params))
420        .responses(
421            ResponsesBuilder::new()
422                .response("204", Response::new("No Content"))
423                .response("400", Response::new("Bad Request"))
424                .response("404", Response::new("Not Found"))
425                .build(),
426        )
427        .build()
428}
429
430fn bulk_create_operation(
431    entity: &ResolvedEntity,
432    op_suffix: &str,
433    include_package_id_param: bool,
434) -> Operation {
435    let mut params = vec![x_tenant_id_header()];
436    if include_package_id_param {
437        params.push(package_id_param());
438    }
439    let item_schema = entity_body_schema(entity, true);
440    let body = RequestBodyBuilder::new()
441        .description(Some(
442            "JSON array of objects; each has shape from _sys_columns (same as create body).",
443        ))
444        .content(
445            "application/json",
446            Content::new(Some(RefOr::T(Schema::Array(
447                utoipa::openapi::schema::ArrayBuilder::new()
448                    .items(RefOr::T(item_schema))
449                    .build(),
450            )))),
451        )
452        .required(Some(Required::True))
453        .build();
454    OperationBuilder::new()
455        .summary(Some(format!("Bulk create {}", entity.path_segment)))
456        .description(Some(format!("Create multiple {}.", entity.path_segment)))
457        .operation_id(Some(format!(
458            "bulk_create_{}{}",
459            entity.path_segment, op_suffix
460        )))
461        .parameters(Some(params))
462        .request_body(Some(body))
463        .responses(
464            ResponsesBuilder::new()
465                .response("201", Response::new("Created"))
466                .response("400", Response::new("Bad Request"))
467                .build(),
468        )
469        .build()
470}
471
472fn bulk_update_operation(
473    entity: &ResolvedEntity,
474    op_suffix: &str,
475    include_package_id_param: bool,
476) -> Operation {
477    let mut params = vec![x_tenant_id_header()];
478    if include_package_id_param {
479        params.push(package_id_param());
480    }
481    let item_schema = entity_body_schema(entity, false);
482    let body = RequestBodyBuilder::new()
483        .description(Some(
484            "JSON array of objects; each must include id and fields from _sys_columns to update (camelCase, partial).",
485        ))
486        .content(
487            "application/json",
488            Content::new(Some(RefOr::T(Schema::Array(
489                utoipa::openapi::schema::ArrayBuilder::new()
490                    .items(RefOr::T(item_schema))
491                    .build(),
492            )))),
493        )
494        .required(Some(Required::True))
495        .build();
496    OperationBuilder::new()
497        .summary(Some(format!("Bulk update {}", entity.path_segment)))
498        .description(Some(format!("Update multiple {}.", entity.path_segment)))
499        .operation_id(Some(format!(
500            "bulk_update_{}{}",
501            entity.path_segment, op_suffix
502        )))
503        .parameters(Some(params))
504        .request_body(Some(body))
505        .responses(default_responses().build())
506        .build()
507}
508
509/// Child create-body schema for a graph include: like `entity_body_schema(child, true)` but
510/// omits the relationship FK column (it is filled automatically from the parent, and the handler
511/// rejects requests that set it).
512fn graph_child_body_schema(child: &ResolvedEntity, fk_column: &str) -> Schema {
513    let mut builder = ObjectBuilder::new()
514        .schema_type(SchemaType::new(Type::Object))
515        .description(Some(format!(
516            "Fields for {} (camelCase). The relationship column '{}' is set automatically from the parent and must be omitted.",
517            child.path_segment,
518            to_camel_case(fk_column)
519        )));
520    let mut required = Vec::new();
521    for col in &child.columns {
522        if child.sensitive_columns.contains(&col.name) || col.name == *fk_column {
523            continue;
524        }
525        let camel = to_camel_case(&col.name);
526        let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
527        builder = builder.property(camel.clone(), RefOr::T(prop_schema));
528        if !col.nullable && !col.has_default {
529            required.push(camel);
530        }
531    }
532    for r in &required {
533        builder = builder.required(r.clone());
534    }
535    Schema::Object(builder.into())
536}
537
538/// POST `/:entity/graph` — insert a parent and its to-many FK-children atomically.
539/// Body: `{ "data": { ...parent... }, "include": { "<toManyName>": child | [child, ...] } }`.
540fn graph_create_operation(
541    entity: &ResolvedEntity,
542    model: &ResolvedModel,
543    op_suffix: &str,
544    include_package_id_param: bool,
545) -> Operation {
546    let mut params = vec![x_tenant_id_header()];
547    if include_package_id_param {
548        params.push(package_id_param());
549    }
550
551    // include: each to-many relationship name → a single child object or an array of them.
552    let mut include_builder = ObjectBuilder::new()
553        .schema_type(SchemaType::new(Type::Object))
554        .description(Some(
555            "Related FK-children inserted atomically with the parent. Each key is a to-many include name (same as ?include=); the value is a single object or an array.".to_string(),
556        ));
557    for inc in &entity.includes {
558        if !matches!(inc.direction, IncludeDirection::ToMany) {
559            continue;
560        }
561        let Some(child) = model.entity_by_path(&inc.related_path_segment) else {
562            continue;
563        };
564        if !child.operations.iter().any(|o| o == "create") {
565            continue;
566        }
567        let child_schema = graph_child_body_schema(child, &inc.their_key_column);
568        let one_of = OneOfBuilder::new()
569            .item(RefOr::T(child_schema.clone()))
570            .item(RefOr::T(Schema::Array(
571                ArrayBuilder::new().items(RefOr::T(child_schema)).build(),
572            )))
573            .build();
574        include_builder =
575            include_builder.property(inc.name.clone(), RefOr::T(Schema::OneOf(one_of)));
576    }
577
578    let req_schema = ObjectBuilder::new()
579        .schema_type(SchemaType::new(Type::Object))
580        .property("data", RefOr::T(entity_body_schema(entity, true)))
581        .required("data")
582        .property("include", RefOr::T(Schema::Object(include_builder.into())))
583        .build();
584
585    let body = RequestBodyBuilder::new()
586        .description(Some(
587            "Parent record under 'data' plus optional nested FK-children under 'include'. Inserted atomically in one transaction.",
588        ))
589        .content(
590            "application/json",
591            Content::new(Some(RefOr::T(Schema::Object(req_schema)))),
592        )
593        .required(Some(Required::True))
594        .build();
595
596    OperationBuilder::new()
597        .summary(Some(format!(
598            "Create {} with related children (atomic)",
599            entity.path_segment
600        )))
601        .description(Some(format!(
602            "Insert a {} and its to-many FK-children in a single transaction. Each child's FK is filled from the new parent id; any failure rolls the whole request back.",
603            entity.path_segment
604        )))
605        .operation_id(Some(format!(
606            "create_graph_{}{}",
607            entity.path_segment, op_suffix
608        )))
609        .parameters(Some(params))
610        .request_body(Some(body))
611        .responses(
612            ResponsesBuilder::new()
613                .response("201", Response::new("Created"))
614                .response("400", Response::new("Bad Request"))
615                .response("422", Response::new("Validation Error"))
616                .build(),
617        )
618        .build()
619}
620
621/// Add entity paths for one model.
622/// - For default model: paths are `{base}/{path_segment}` (no package segment).
623/// - For package models: paths are `{base}/package/{package_id}/{path_segment}` with the concrete package id.
624fn add_entity_paths(
625    mut builder: PathsBuilder,
626    base: &str,
627    model: &ResolvedModel,
628    use_package_param: bool,
629    package_id_literal: Option<&str>,
630) -> PathsBuilder {
631    let path_prefix = if use_package_param {
632        match package_id_literal {
633            Some(pkg) => format!("{}/package/{}", base, pkg),
634            None => format!("{}/package/{{packageId}}", base),
635        }
636    } else {
637        base.to_string()
638    };
639    let op_suffix = if use_package_param { "_package" } else { "" };
640
641    for entity in &model.entities {
642        let seg = &entity.path_segment;
643        let list_path = format!("{}/{}", path_prefix, seg);
644        let by_id_path = format!("{}/{}/{{id}}", path_prefix, seg);
645        let bulk_path = format!("{}/{}/bulk", path_prefix, seg);
646
647        let has_list = entity.operations.iter().any(|o| o == "read");
648        let has_create = entity.operations.iter().any(|o| o == "create");
649        if has_list || has_create {
650            let mut list_item = PathItemBuilder::new();
651            if has_list {
652                list_item = list_item.operation(
653                    HttpMethod::Get,
654                    list_operation(entity, op_suffix, use_package_param),
655                );
656            }
657            if has_create {
658                list_item = list_item.operation(
659                    HttpMethod::Post,
660                    create_operation(entity, op_suffix, use_package_param),
661                );
662            }
663            builder = builder.path(list_path, list_item.build());
664        }
665
666        let has_read = entity.operations.iter().any(|o| o == "read");
667        let has_update = entity.operations.iter().any(|o| o == "update");
668        let has_delete = entity.operations.iter().any(|o| o == "delete");
669        if has_read || has_update || has_delete {
670            let mut by_id_item = PathItemBuilder::new();
671            if has_read {
672                by_id_item = by_id_item.operation(
673                    HttpMethod::Get,
674                    read_operation(entity, op_suffix, use_package_param),
675                );
676            }
677            if has_update {
678                by_id_item = by_id_item.operation(
679                    HttpMethod::Patch,
680                    update_operation(entity, op_suffix, use_package_param),
681                );
682            }
683            if has_delete {
684                by_id_item = by_id_item.operation(
685                    HttpMethod::Delete,
686                    delete_operation(entity, op_suffix, use_package_param),
687                );
688            }
689            builder = builder.path(by_id_path, by_id_item.build());
690        }
691
692        let has_bulk_create = entity.operations.iter().any(|o| o == "bulk_create");
693        let has_bulk_update = entity.operations.iter().any(|o| o == "bulk_update");
694        if has_bulk_create || has_bulk_update {
695            let mut bulk_item = PathItemBuilder::new();
696            if has_bulk_create {
697                bulk_item = bulk_item.operation(
698                    HttpMethod::Post,
699                    bulk_create_operation(entity, op_suffix, use_package_param),
700                );
701            }
702            if has_bulk_update {
703                bulk_item = bulk_item.operation(
704                    HttpMethod::Patch,
705                    bulk_update_operation(entity, op_suffix, use_package_param),
706                );
707            }
708            builder = builder.path(bulk_path, bulk_item.build());
709        }
710
711        // Atomic parent+children insert — opt-in via the "create_graph" operation (like
712        // bulk_create), and only when the entity has at least one to-many (FK-in-child)
713        // relationship to nest under `include`.
714        let has_graph = entity.operations.iter().any(|o| o == "create_graph")
715            && entity
716                .includes
717                .iter()
718                .any(|i| matches!(i.direction, IncludeDirection::ToMany));
719        if has_graph {
720            let graph_path = format!("{}/{}/graph", path_prefix, seg);
721            builder = builder.path(
722                graph_path,
723                PathItemBuilder::new()
724                    .operation(
725                        HttpMethod::Post,
726                        graph_create_operation(entity, model, op_suffix, use_package_param),
727                    )
728                    .build(),
729            );
730        }
731
732        // Extensible-field admin routes — available in both default and package-scoped forms,
733        // for entities that declare at least one `extensible` JSON column.
734        if !entity.extensible_columns.is_empty() {
735            let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity, op_suffix);
736            builder = builder.path(
737                format!("{}/{}/extensible-fields", path_prefix, seg),
738                PathItemBuilder::new()
739                    .operation(HttpMethod::Get, xf_get)
740                    .operation(HttpMethod::Put, xf_put)
741                    .operation(HttpMethod::Delete, xf_delete)
742                    .build(),
743            );
744            let (idx_get, idx_post) = extensible_indexes_operations(entity, op_suffix);
745            builder = builder.path(
746                format!("{}/{}/extensible-fields/indexes", path_prefix, seg),
747                PathItemBuilder::new()
748                    .operation(HttpMethod::Get, idx_get)
749                    .operation(HttpMethod::Post, idx_post)
750                    .build(),
751            );
752        }
753    }
754    builder
755}
756
757/// GET/PUT/DELETE operations for `/:entity/extensible-fields` (per-tenant registry admin).
758fn extensible_fields_operations(
759    entity: &ResolvedEntity,
760    op_suffix: &str,
761) -> (Operation, Operation, Operation) {
762    let seg = &entity.path_segment;
763    let get = OperationBuilder::new()
764        .summary(Some("Get extensible-field registry"))
765        .description(Some(
766            "Return the tenant's extensible-field registry document for this entity (or {} when unset).",
767        ))
768        .operation_id(Some(format!("get_extensible_fields_{}{}", seg, op_suffix)))
769        .parameters(Some(vec![x_tenant_id_header()]))
770        .responses(default_responses().build())
771        .build();
772    let put = OperationBuilder::new()
773        .summary(Some("Replace extensible-field registry"))
774        .description(Some(
775            "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}]}.",
776        ))
777        .operation_id(Some(format!("put_extensible_fields_{}{}", seg, op_suffix)))
778        .parameters(Some(vec![x_tenant_id_header()]))
779        .request_body(Some(
780            RequestBodyBuilder::new()
781                .description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
782                .content(
783                    "application/json",
784                    Content::new(Some(RefOr::T(Schema::Object(
785                        ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
786                    )))),
787                )
788                .required(Some(Required::True))
789                .build(),
790        ))
791        .responses(default_responses().build())
792        .build();
793    let delete = OperationBuilder::new()
794        .summary(Some("Clear extensible-field registry"))
795        .description(Some(
796            "Delete the tenant's registry document for this entity.",
797        ))
798        .operation_id(Some(format!(
799            "delete_extensible_fields_{}{}",
800            seg, op_suffix
801        )))
802        .parameters(Some(vec![x_tenant_id_header()]))
803        .responses(default_responses().build())
804        .build();
805    (get, put, delete)
806}
807
808/// GET/POST operations for `/:entity/extensible-fields/indexes` (suggest / apply index DDL).
809fn extensible_indexes_operations(
810    entity: &ResolvedEntity,
811    op_suffix: &str,
812) -> (Operation, Operation) {
813    let seg = &entity.path_segment;
814    let get = OperationBuilder::new()
815        .summary(Some("Suggested indexes for extensible fields"))
816        .description(Some(
817            "Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
818        ))
819        .operation_id(Some(format!("get_extensible_field_indexes_{}{}", seg, op_suffix)))
820        .parameters(Some(vec![x_tenant_id_header()]))
821        .responses(default_responses().build())
822        .build();
823    let post = OperationBuilder::new()
824        .summary(Some("Apply extensible-field indexes"))
825        .description(Some(
826            "Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
827        ))
828        .operation_id(Some(format!("apply_extensible_field_indexes_{}{}", seg, op_suffix)))
829        .parameters(Some(vec![x_tenant_id_header()]))
830        .responses(default_responses().build())
831        .build();
832    (get, post)
833}
834
835fn kv_namespace_param() -> Parameter {
836    ParameterBuilder::new()
837        .name("namespace")
838        .parameter_in(ParameterIn::Path)
839        .required(Required::True)
840        .description(Some("KV store namespace (from _sys_kv_stores)."))
841        .schema(Some(RefOr::T(Schema::Object(
842            utoipa::openapi::schema::ObjectBuilder::new()
843                .schema_type(SchemaType::new(Type::String))
844                .into(),
845        ))))
846        .build()
847}
848
849fn kv_list_keys_operation() -> Operation {
850    OperationBuilder::new()
851        .summary(Some("List KV keys in namespace"))
852        .description(Some(
853            "List all keys and values in the given package and namespace.",
854        ))
855        .operation_id(Some("kv_list_keys"))
856        .parameters(Some(vec![
857            x_tenant_id_header(),
858            package_id_param(),
859            kv_namespace_param(),
860        ]))
861        .responses(default_responses().build())
862        .build()
863}
864
865fn kv_key_param() -> Parameter {
866    ParameterBuilder::new()
867        .name("key")
868        .parameter_in(ParameterIn::Path)
869        .required(Required::True)
870        .description(Some("KV key"))
871        .schema(Some(RefOr::T(Schema::Object(
872            utoipa::openapi::schema::ObjectBuilder::new()
873                .schema_type(SchemaType::new(Type::String))
874                .into(),
875        ))))
876        .build()
877}
878
879fn kv_key_operations() -> (Operation, Operation, Operation) {
880    let get_op = OperationBuilder::new()
881        .summary(Some("Get KV value by key"))
882        .description(Some("Get value for key in package and namespace."))
883        .operation_id(Some("kv_get"))
884        .parameters(Some(vec![
885            x_tenant_id_header(),
886            package_id_param(),
887            kv_namespace_param(),
888            kv_key_param(),
889        ]))
890        .responses(default_responses().build())
891        .build();
892
893    let put_op = OperationBuilder::new()
894        .summary(Some("Set KV value (upsert)"))
895        .description(Some(
896            "Set or overwrite value for key. Body is arbitrary JSON.",
897        ))
898        .operation_id(Some("kv_put"))
899        .parameters(Some(vec![
900            x_tenant_id_header(),
901            package_id_param(),
902            kv_namespace_param(),
903            kv_key_param(),
904        ]))
905        .request_body(Some(
906            RequestBodyBuilder::new()
907                .description(Some("JSON value (string, number, object, or array)"))
908                .content(
909                    "application/json",
910                    Content::new(Some(RefOr::T(json_object_schema()))),
911                )
912                .required(Some(Required::True))
913                .build(),
914        ))
915        .responses(
916            ResponsesBuilder::new()
917                .response("200", Response::new("OK"))
918                .response("400", Response::new("Bad Request"))
919                .build(),
920        )
921        .build();
922
923    let delete_op = OperationBuilder::new()
924        .summary(Some("Delete KV key"))
925        .description(Some("Delete key. Returns 204 No Content."))
926        .operation_id(Some("kv_delete"))
927        .parameters(Some(vec![
928            x_tenant_id_header(),
929            package_id_param(),
930            kv_namespace_param(),
931            kv_key_param(),
932        ]))
933        .responses(
934            ResponsesBuilder::new()
935                .response("204", Response::new("No Content"))
936                .response("404", Response::new("Not Found"))
937                .build(),
938        )
939        .build();
940
941    (get_op, put_op, delete_op)
942}
943
944/// Add KV store paths with concrete package ids and {namespace}/{key}.
945fn add_kv_paths(
946    mut builder: PathsBuilder,
947    base: &str,
948    package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
949) -> PathsBuilder {
950    for (package_id, stores) in package_kv_stores {
951        if stores.is_empty() {
952            continue;
953        }
954        let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
955        let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
956
957        let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
958        builder = builder.path(list_path, list_item.build());
959
960        let (get_op, put_op, delete_op) = kv_key_operations();
961        let key_item = PathItemBuilder::new()
962            .operation(HttpMethod::Get, get_op)
963            .operation(HttpMethod::Put, put_op)
964            .operation(HttpMethod::Delete, delete_op);
965        builder = builder.path(key_path, key_item.build());
966    }
967    builder
968}
969
970/// Add config API paths: install/uninstall package and GET/POST per config kind.
971fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
972    let install_path = format!("{}/config/package", base);
973    let install_op = OperationBuilder::new()
974        .summary(Some("Install package"))
975        .description(Some(
976            "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).",
977        ))
978        .operation_id(Some("config_install_package"))
979        .parameters(Some(vec![x_tenant_id_header()]))
980        .request_body(Some(
981            RequestBodyBuilder::new()
982                .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
983                .content(
984                    "multipart/form-data",
985                    Content::new(Some(RefOr::T(Schema::Object(
986                        ObjectBuilder::new()
987                            .schema_type(SchemaType::new(Type::Object))
988                            .property(
989                                "file",
990                                Schema::Object(
991                                    ObjectBuilder::new()
992                                        .schema_type(SchemaType::new(Type::String))
993                                        .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
994                                            utoipa::openapi::schema::KnownFormat::Binary,
995                                        )))
996                                        .description(Some("ZIP file (manifest.json + config JSONs)"))
997                                        .into(),
998                                ),
999                            )
1000                            .into(),
1001                    )))),
1002                )
1003                .required(Some(Required::True))
1004                .build(),
1005        ))
1006        .responses(
1007            ResponsesBuilder::new()
1008                .response("200", Response::new("OK"))
1009                .response("400", Response::new("Bad Request"))
1010                .build(),
1011        )
1012        .build();
1013    let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
1014    builder = builder.path(install_path, install_item.build());
1015
1016    let uninstall_path = format!("{}/config/package/{{packageId}}", base);
1017    let uninstall_op = OperationBuilder::new()
1018        .summary(Some("Uninstall package"))
1019        .description(Some(
1020            "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
1021        ))
1022        .operation_id(Some("config_uninstall_package"))
1023        .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
1024        .responses(
1025            ResponsesBuilder::new()
1026                .response("200", Response::new("OK"))
1027                .response("404", Response::new("Not Found"))
1028                .build(),
1029        )
1030        .build();
1031    let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
1032    builder = builder.path(uninstall_path, uninstall_item.build());
1033
1034    let config_kinds = [
1035        ("schemas", "Schema definitions"),
1036        ("enums", "Enum types"),
1037        ("tables", "Table definitions"),
1038        ("columns", "Column definitions"),
1039        ("indexes", "Index definitions"),
1040        ("relationships", "Relationship definitions"),
1041        ("api_entities", "API entity definitions"),
1042        ("kv_stores", "KV store definitions"),
1043    ];
1044    for (kind, description) in config_kinds {
1045        let path = format!("{}/config/{}", base, kind);
1046        let get_op = OperationBuilder::new()
1047            .summary(Some(format!("Get {}", kind)))
1048            .description(Some(format!(
1049                "Get {} (from _sys_{}). {}",
1050                description, kind, "X-Tenant-ID required."
1051            )))
1052            .operation_id(Some(format!("config_get_{}", kind)))
1053            .parameters(Some(vec![x_tenant_id_header()]))
1054            .responses(default_responses().build())
1055            .build();
1056        let post_body = RequestBodyBuilder::new()
1057            .description(Some(format!("JSON array of {} records.", description)))
1058            .content(
1059                "application/json",
1060                Content::new(Some(RefOr::T(Schema::Array(
1061                    utoipa::openapi::schema::ArrayBuilder::new()
1062                        .items(RefOr::T(json_object_schema()))
1063                        .into(),
1064                )))),
1065            )
1066            .required(Some(Required::True))
1067            .build();
1068        let post_op = OperationBuilder::new()
1069            .summary(Some(format!("Replace {}", kind)))
1070            .description(Some(format!(
1071                "Replace {} for the default package. Runs migrations when rows change.",
1072                kind
1073            )))
1074            .operation_id(Some(format!("config_post_{}", kind)))
1075            .parameters(Some(vec![x_tenant_id_header()]))
1076            .request_body(Some(post_body))
1077            .responses(default_responses().build())
1078            .build();
1079        let item = PathItemBuilder::new()
1080            .operation(HttpMethod::Get, get_op)
1081            .operation(HttpMethod::Post, post_op);
1082        builder = builder.path(path, item.build());
1083    }
1084    builder
1085}
1086
1087/// Build full OpenAPI spec for entity APIs: default model paths plus package-scoped paths
1088/// with concrete package ids, plus KV paths with {namespace}/{key} per package.
1089pub fn build_spec(
1090    default_model: &ResolvedModel,
1091    base_path: &str,
1092    package_models: &HashMap<String, ResolvedModel>,
1093    package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1094) -> OpenApi {
1095    let server = build_server();
1096    let mut builder = PathsBuilder::new();
1097    builder = add_config_paths(builder, base_path);
1098    builder = add_entity_paths(builder, base_path, default_model, false, None);
1099    for (package_id, model) in package_models {
1100        if !model.entities.is_empty() {
1101            builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
1102        }
1103    }
1104    builder = add_kv_paths(builder, base_path, package_kv_stores);
1105    let paths = builder.build();
1106    OpenApiBuilder::new()
1107        .info(
1108            Info::builder()
1109                .title("Architect API")
1110                .version(env!("CARGO_PKG_VERSION"))
1111                .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
1112                .build(),
1113        )
1114        .servers(Some(vec![server]))
1115        .paths(paths)
1116        .build()
1117}
1118
1119/// GET /spec — return OpenAPI JSON for entity APIs. Default (unprefixed) routes come from
1120/// state.model; package-scoped routes are built by listing _sys_packages and loading each
1121/// package's config from _sys_* tables (same source of truth as runtime routes).
1122pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
1123    let default_model = state.model.read().expect("model read lock").clone();
1124    let base_path = "/api/v1";
1125
1126    let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
1127    let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
1128    let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
1129    for package_id in package_ids {
1130        if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
1131            if let Ok(model) = resolve(&config) {
1132                package_models.insert(package_id.clone(), model);
1133            }
1134            package_kv_stores.insert(package_id, config.kv_stores);
1135        }
1136    }
1137
1138    let spec = build_spec(
1139        &default_model,
1140        base_path,
1141        &package_models,
1142        &package_kv_stores,
1143    );
1144    Json(spec)
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150    use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
1151    use std::collections::{HashMap, HashSet};
1152
1153    fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
1154        ResolvedEntity {
1155            table_id: seg.to_string(),
1156            schema_name: "public".into(),
1157            table_name: seg.to_string(),
1158            path_segment: seg.to_string(),
1159            pk_columns: vec!["id".into()],
1160            pk_type: PkType::Uuid,
1161            columns: vec![],
1162            operations: vec![
1163                "read".into(),
1164                "create".into(),
1165                "update".into(),
1166                "delete".into(),
1167            ],
1168            sensitive_columns: HashSet::new(),
1169            includes: vec![],
1170            validation: HashMap::new(),
1171            events: vec![],
1172            archive_field: None,
1173            package_id: "_default".into(),
1174            audit_log: false,
1175            global: false,
1176            parent_ref_column: None,
1177            versioning: None,
1178            mcp: None,
1179            extensible_columns,
1180        }
1181    }
1182
1183    #[test]
1184    fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
1185        let model = ResolvedModel {
1186            entities: vec![
1187                entity("products", vec!["attributes".into()]),
1188                entity("orders", vec![]),
1189            ],
1190            entity_by_path: HashMap::new(),
1191        };
1192        let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
1193        let json = serde_json::to_string(&spec).expect("serialize spec");
1194
1195        // The extensible entity exposes both admin paths.
1196        assert!(json.contains("/api/v1/products/extensible-fields"));
1197        assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
1198        // The non-extensible entity does not.
1199        assert!(!json.contains("/api/v1/orders/extensible-fields"));
1200    }
1201
1202    #[test]
1203    fn spec_lists_package_scoped_extensible_field_paths() {
1204        let default_model = ResolvedModel {
1205            entities: vec![entity("products", vec!["attributes".into()])],
1206            entity_by_path: HashMap::new(),
1207        };
1208        let mut package_models = HashMap::new();
1209        package_models.insert(
1210            "billing".to_string(),
1211            ResolvedModel {
1212                entities: vec![entity("invoices", vec!["meta".into()])],
1213                entity_by_path: HashMap::new(),
1214            },
1215        );
1216        let spec = build_spec(&default_model, "/api/v1", &package_models, &HashMap::new());
1217        let json = serde_json::to_string(&spec).expect("serialize spec");
1218
1219        // Package-scoped admin paths are emitted for the package's extensible entity.
1220        assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields"));
1221        assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields/indexes"));
1222    }
1223}