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
509fn bulk_delete_operation(
510    entity: &ResolvedEntity,
511    op_suffix: &str,
512    include_package_id_param: bool,
513) -> Operation {
514    let mut params = vec![x_tenant_id_header()];
515    if include_package_id_param {
516        params.push(package_id_param());
517    }
518    // Body: { "ids": [ ...pk values... ] }. A bare JSON array of ids is also accepted.
519    let ids_schema = Schema::Array(
520        utoipa::openapi::schema::ArrayBuilder::new()
521            .items(RefOr::T(Schema::Object(
522                ObjectBuilder::new()
523                    .schema_type(SchemaType::new(Type::String))
524                    .build(),
525            )))
526            .build(),
527    );
528    let body_schema = ObjectBuilder::new()
529        .schema_type(SchemaType::new(Type::Object))
530        .property("ids", RefOr::T(ids_schema))
531        .required("ids")
532        .build();
533    let body = RequestBodyBuilder::new()
534        .description(Some(
535            "Object with an `ids` array of primary-key values to delete (a bare JSON array of ids is also accepted).",
536        ))
537        .content(
538            "application/json",
539            Content::new(Some(RefOr::T(Schema::Object(body_schema)))),
540        )
541        .required(Some(Required::True))
542        .build();
543    OperationBuilder::new()
544        .summary(Some(format!("Bulk delete {}", entity.path_segment)))
545        .description(Some(format!(
546            "Delete multiple {} by id. Ids that do not exist are skipped.",
547            entity.path_segment
548        )))
549        .operation_id(Some(format!(
550            "bulk_delete_{}{}",
551            entity.path_segment, op_suffix
552        )))
553        .parameters(Some(params))
554        .request_body(Some(body))
555        .responses(default_responses().build())
556        .build()
557}
558
559/// Child create-body schema for a graph include: like `entity_body_schema(child, true)` but
560/// omits the relationship FK column (it is filled automatically from the parent, and the handler
561/// rejects requests that set it).
562fn graph_child_body_schema(child: &ResolvedEntity, fk_column: &str) -> Schema {
563    let mut builder = ObjectBuilder::new()
564        .schema_type(SchemaType::new(Type::Object))
565        .description(Some(format!(
566            "Fields for {} (camelCase). The relationship column '{}' is set automatically from the parent and must be omitted.",
567            child.path_segment,
568            to_camel_case(fk_column)
569        )));
570    let mut required = Vec::new();
571    for col in &child.columns {
572        if child.sensitive_columns.contains(&col.name) || col.name == *fk_column {
573            continue;
574        }
575        let camel = to_camel_case(&col.name);
576        let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
577        builder = builder.property(camel.clone(), RefOr::T(prop_schema));
578        if !col.nullable && !col.has_default {
579            required.push(camel);
580        }
581    }
582    for r in &required {
583        builder = builder.required(r.clone());
584    }
585    Schema::Object(builder.into())
586}
587
588/// POST `/:entity/graph` — insert a parent and its to-many FK-children atomically.
589/// Body: `{ "data": { ...parent... }, "include": { "<toManyName>": child | [child, ...] } }`.
590fn graph_create_operation(
591    entity: &ResolvedEntity,
592    model: &ResolvedModel,
593    op_suffix: &str,
594    include_package_id_param: bool,
595) -> Operation {
596    let mut params = vec![x_tenant_id_header()];
597    if include_package_id_param {
598        params.push(package_id_param());
599    }
600
601    // include: each to-many relationship name → a single child object or an array of them.
602    let mut include_builder = ObjectBuilder::new()
603        .schema_type(SchemaType::new(Type::Object))
604        .description(Some(
605            "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(),
606        ));
607    for inc in &entity.includes {
608        if !matches!(inc.direction, IncludeDirection::ToMany) {
609            continue;
610        }
611        let Some(child) = model.entity_by_path(&inc.related_path_segment) else {
612            continue;
613        };
614        if !child.operations.iter().any(|o| o == "create") {
615            continue;
616        }
617        let child_schema = graph_child_body_schema(child, &inc.their_key_column);
618        let one_of = OneOfBuilder::new()
619            .item(RefOr::T(child_schema.clone()))
620            .item(RefOr::T(Schema::Array(
621                ArrayBuilder::new().items(RefOr::T(child_schema)).build(),
622            )))
623            .build();
624        include_builder =
625            include_builder.property(inc.name.clone(), RefOr::T(Schema::OneOf(one_of)));
626    }
627
628    let req_schema = ObjectBuilder::new()
629        .schema_type(SchemaType::new(Type::Object))
630        .property("data", RefOr::T(entity_body_schema(entity, true)))
631        .required("data")
632        .property("include", RefOr::T(Schema::Object(include_builder.into())))
633        .build();
634
635    let body = RequestBodyBuilder::new()
636        .description(Some(
637            "Parent record under 'data' plus optional nested FK-children under 'include'. Inserted atomically in one transaction.",
638        ))
639        .content(
640            "application/json",
641            Content::new(Some(RefOr::T(Schema::Object(req_schema)))),
642        )
643        .required(Some(Required::True))
644        .build();
645
646    OperationBuilder::new()
647        .summary(Some(format!(
648            "Create {} with related children (atomic)",
649            entity.path_segment
650        )))
651        .description(Some(format!(
652            "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.",
653            entity.path_segment
654        )))
655        .operation_id(Some(format!(
656            "create_graph_{}{}",
657            entity.path_segment, op_suffix
658        )))
659        .parameters(Some(params))
660        .request_body(Some(body))
661        .responses(
662            ResponsesBuilder::new()
663                .response("201", Response::new("Created"))
664                .response("400", Response::new("Bad Request"))
665                .response("422", Response::new("Validation Error"))
666                .build(),
667        )
668        .build()
669}
670
671/// Add entity paths for one model.
672/// - For default model: paths are `{base}/{path_segment}` (no package segment).
673/// - For package models: paths are `{base}/package/{package_id}/{path_segment}` with the concrete package id.
674fn add_entity_paths(
675    mut builder: PathsBuilder,
676    base: &str,
677    model: &ResolvedModel,
678    use_package_param: bool,
679    package_id_literal: Option<&str>,
680) -> PathsBuilder {
681    let path_prefix = if use_package_param {
682        match package_id_literal {
683            Some(pkg) => format!("{}/package/{}", base, pkg),
684            None => format!("{}/package/{{packageId}}", base),
685        }
686    } else {
687        base.to_string()
688    };
689    let op_suffix = if use_package_param { "_package" } else { "" };
690
691    for entity in &model.entities {
692        let seg = &entity.path_segment;
693        let list_path = format!("{}/{}", path_prefix, seg);
694        let by_id_path = format!("{}/{}/{{id}}", path_prefix, seg);
695        let bulk_path = format!("{}/{}/bulk", path_prefix, seg);
696
697        let has_list = entity.operations.iter().any(|o| o == "read");
698        let has_create = entity.operations.iter().any(|o| o == "create");
699        if has_list || has_create {
700            let mut list_item = PathItemBuilder::new();
701            if has_list {
702                list_item = list_item.operation(
703                    HttpMethod::Get,
704                    list_operation(entity, op_suffix, use_package_param),
705                );
706            }
707            if has_create {
708                list_item = list_item.operation(
709                    HttpMethod::Post,
710                    create_operation(entity, op_suffix, use_package_param),
711                );
712            }
713            builder = builder.path(list_path, list_item.build());
714        }
715
716        let has_read = entity.operations.iter().any(|o| o == "read");
717        let has_update = entity.operations.iter().any(|o| o == "update");
718        let has_delete = entity.operations.iter().any(|o| o == "delete");
719        if has_read || has_update || has_delete {
720            let mut by_id_item = PathItemBuilder::new();
721            if has_read {
722                by_id_item = by_id_item.operation(
723                    HttpMethod::Get,
724                    read_operation(entity, op_suffix, use_package_param),
725                );
726            }
727            if has_update {
728                by_id_item = by_id_item.operation(
729                    HttpMethod::Patch,
730                    update_operation(entity, op_suffix, use_package_param),
731                );
732            }
733            if has_delete {
734                by_id_item = by_id_item.operation(
735                    HttpMethod::Delete,
736                    delete_operation(entity, op_suffix, use_package_param),
737                );
738            }
739            builder = builder.path(by_id_path, by_id_item.build());
740        }
741
742        let has_bulk_create = entity.operations.iter().any(|o| o == "bulk_create");
743        let has_bulk_update = entity.operations.iter().any(|o| o == "bulk_update");
744        let has_bulk_delete = entity.operations.iter().any(|o| o == "bulk_delete");
745        if has_bulk_create || has_bulk_update || has_bulk_delete {
746            let mut bulk_item = PathItemBuilder::new();
747            if has_bulk_create {
748                bulk_item = bulk_item.operation(
749                    HttpMethod::Post,
750                    bulk_create_operation(entity, op_suffix, use_package_param),
751                );
752            }
753            if has_bulk_update {
754                bulk_item = bulk_item.operation(
755                    HttpMethod::Patch,
756                    bulk_update_operation(entity, op_suffix, use_package_param),
757                );
758            }
759            if has_bulk_delete {
760                bulk_item = bulk_item.operation(
761                    HttpMethod::Delete,
762                    bulk_delete_operation(entity, op_suffix, use_package_param),
763                );
764            }
765            builder = builder.path(bulk_path, bulk_item.build());
766        }
767
768        // Atomic parent+children insert — opt-in via the "create_graph" operation (like
769        // bulk_create), and only when the entity has at least one to-many (FK-in-child)
770        // relationship to nest under `include`.
771        let has_graph = entity.operations.iter().any(|o| o == "create_graph")
772            && entity
773                .includes
774                .iter()
775                .any(|i| matches!(i.direction, IncludeDirection::ToMany));
776        if has_graph {
777            let graph_path = format!("{}/{}/graph", path_prefix, seg);
778            builder = builder.path(
779                graph_path,
780                PathItemBuilder::new()
781                    .operation(
782                        HttpMethod::Post,
783                        graph_create_operation(entity, model, op_suffix, use_package_param),
784                    )
785                    .build(),
786            );
787        }
788
789        // Extensible-field admin routes — available in both default and package-scoped forms,
790        // for entities that declare at least one `extensible` JSON column.
791        if !entity.extensible_columns.is_empty() {
792            let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity, op_suffix);
793            builder = builder.path(
794                format!("{}/{}/extensible-fields", path_prefix, seg),
795                PathItemBuilder::new()
796                    .operation(HttpMethod::Get, xf_get)
797                    .operation(HttpMethod::Put, xf_put)
798                    .operation(HttpMethod::Delete, xf_delete)
799                    .build(),
800            );
801            let (idx_get, idx_post) = extensible_indexes_operations(entity, op_suffix);
802            builder = builder.path(
803                format!("{}/{}/extensible-fields/indexes", path_prefix, seg),
804                PathItemBuilder::new()
805                    .operation(HttpMethod::Get, idx_get)
806                    .operation(HttpMethod::Post, idx_post)
807                    .build(),
808            );
809        }
810    }
811    builder
812}
813
814/// GET/PUT/DELETE operations for `/:entity/extensible-fields` (per-tenant registry admin).
815fn extensible_fields_operations(
816    entity: &ResolvedEntity,
817    op_suffix: &str,
818) -> (Operation, Operation, Operation) {
819    let seg = &entity.path_segment;
820    let get = OperationBuilder::new()
821        .summary(Some("Get extensible-field registry"))
822        .description(Some(
823            "Return the tenant's extensible-field registry document for this entity (or {} when unset).",
824        ))
825        .operation_id(Some(format!("get_extensible_fields_{}{}", seg, op_suffix)))
826        .parameters(Some(vec![x_tenant_id_header()]))
827        .responses(default_responses().build())
828        .build();
829    let put = OperationBuilder::new()
830        .summary(Some("Replace extensible-field registry"))
831        .description(Some(
832            "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}]}.",
833        ))
834        .operation_id(Some(format!("put_extensible_fields_{}{}", seg, op_suffix)))
835        .parameters(Some(vec![x_tenant_id_header()]))
836        .request_body(Some(
837            RequestBodyBuilder::new()
838                .description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
839                .content(
840                    "application/json",
841                    Content::new(Some(RefOr::T(Schema::Object(
842                        ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
843                    )))),
844                )
845                .required(Some(Required::True))
846                .build(),
847        ))
848        .responses(default_responses().build())
849        .build();
850    let delete = OperationBuilder::new()
851        .summary(Some("Clear extensible-field registry"))
852        .description(Some(
853            "Delete the tenant's registry document for this entity.",
854        ))
855        .operation_id(Some(format!(
856            "delete_extensible_fields_{}{}",
857            seg, op_suffix
858        )))
859        .parameters(Some(vec![x_tenant_id_header()]))
860        .responses(default_responses().build())
861        .build();
862    (get, put, delete)
863}
864
865/// GET/POST operations for `/:entity/extensible-fields/indexes` (suggest / apply index DDL).
866fn extensible_indexes_operations(
867    entity: &ResolvedEntity,
868    op_suffix: &str,
869) -> (Operation, Operation) {
870    let seg = &entity.path_segment;
871    let get = OperationBuilder::new()
872        .summary(Some("Suggested indexes for extensible fields"))
873        .description(Some(
874            "Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
875        ))
876        .operation_id(Some(format!("get_extensible_field_indexes_{}{}", seg, op_suffix)))
877        .parameters(Some(vec![x_tenant_id_header()]))
878        .responses(default_responses().build())
879        .build();
880    let post = OperationBuilder::new()
881        .summary(Some("Apply extensible-field indexes"))
882        .description(Some(
883            "Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
884        ))
885        .operation_id(Some(format!("apply_extensible_field_indexes_{}{}", seg, op_suffix)))
886        .parameters(Some(vec![x_tenant_id_header()]))
887        .responses(default_responses().build())
888        .build();
889    (get, post)
890}
891
892fn kv_namespace_param() -> Parameter {
893    ParameterBuilder::new()
894        .name("namespace")
895        .parameter_in(ParameterIn::Path)
896        .required(Required::True)
897        .description(Some("KV store namespace (from _sys_kv_stores)."))
898        .schema(Some(RefOr::T(Schema::Object(
899            utoipa::openapi::schema::ObjectBuilder::new()
900                .schema_type(SchemaType::new(Type::String))
901                .into(),
902        ))))
903        .build()
904}
905
906fn kv_list_keys_operation() -> Operation {
907    OperationBuilder::new()
908        .summary(Some("List KV keys in namespace"))
909        .description(Some(
910            "List all keys and values in the given package and namespace.",
911        ))
912        .operation_id(Some("kv_list_keys"))
913        .parameters(Some(vec![
914            x_tenant_id_header(),
915            package_id_param(),
916            kv_namespace_param(),
917        ]))
918        .responses(default_responses().build())
919        .build()
920}
921
922fn kv_key_param() -> Parameter {
923    ParameterBuilder::new()
924        .name("key")
925        .parameter_in(ParameterIn::Path)
926        .required(Required::True)
927        .description(Some("KV key"))
928        .schema(Some(RefOr::T(Schema::Object(
929            utoipa::openapi::schema::ObjectBuilder::new()
930                .schema_type(SchemaType::new(Type::String))
931                .into(),
932        ))))
933        .build()
934}
935
936fn kv_key_operations() -> (Operation, Operation, Operation) {
937    let get_op = OperationBuilder::new()
938        .summary(Some("Get KV value by key"))
939        .description(Some("Get value for key in package and namespace."))
940        .operation_id(Some("kv_get"))
941        .parameters(Some(vec![
942            x_tenant_id_header(),
943            package_id_param(),
944            kv_namespace_param(),
945            kv_key_param(),
946        ]))
947        .responses(default_responses().build())
948        .build();
949
950    let put_op = OperationBuilder::new()
951        .summary(Some("Set KV value (upsert)"))
952        .description(Some(
953            "Set or overwrite value for key. Body is arbitrary JSON.",
954        ))
955        .operation_id(Some("kv_put"))
956        .parameters(Some(vec![
957            x_tenant_id_header(),
958            package_id_param(),
959            kv_namespace_param(),
960            kv_key_param(),
961        ]))
962        .request_body(Some(
963            RequestBodyBuilder::new()
964                .description(Some("JSON value (string, number, object, or array)"))
965                .content(
966                    "application/json",
967                    Content::new(Some(RefOr::T(json_object_schema()))),
968                )
969                .required(Some(Required::True))
970                .build(),
971        ))
972        .responses(
973            ResponsesBuilder::new()
974                .response("200", Response::new("OK"))
975                .response("400", Response::new("Bad Request"))
976                .build(),
977        )
978        .build();
979
980    let delete_op = OperationBuilder::new()
981        .summary(Some("Delete KV key"))
982        .description(Some("Delete key. Returns 204 No Content."))
983        .operation_id(Some("kv_delete"))
984        .parameters(Some(vec![
985            x_tenant_id_header(),
986            package_id_param(),
987            kv_namespace_param(),
988            kv_key_param(),
989        ]))
990        .responses(
991            ResponsesBuilder::new()
992                .response("204", Response::new("No Content"))
993                .response("404", Response::new("Not Found"))
994                .build(),
995        )
996        .build();
997
998    (get_op, put_op, delete_op)
999}
1000
1001/// Add KV store paths with concrete package ids and {namespace}/{key}.
1002fn add_kv_paths(
1003    mut builder: PathsBuilder,
1004    base: &str,
1005    package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1006) -> PathsBuilder {
1007    for (package_id, stores) in package_kv_stores {
1008        if stores.is_empty() {
1009            continue;
1010        }
1011        let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
1012        let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
1013
1014        let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
1015        builder = builder.path(list_path, list_item.build());
1016
1017        let (get_op, put_op, delete_op) = kv_key_operations();
1018        let key_item = PathItemBuilder::new()
1019            .operation(HttpMethod::Get, get_op)
1020            .operation(HttpMethod::Put, put_op)
1021            .operation(HttpMethod::Delete, delete_op);
1022        builder = builder.path(key_path, key_item.build());
1023    }
1024    builder
1025}
1026
1027/// Add config API paths: install/uninstall package and GET/POST per config kind.
1028fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
1029    let install_path = format!("{}/config/package", base);
1030    let install_op = OperationBuilder::new()
1031        .summary(Some("Install package"))
1032        .description(Some(
1033            "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).",
1034        ))
1035        .operation_id(Some("config_install_package"))
1036        .parameters(Some(vec![x_tenant_id_header()]))
1037        .request_body(Some(
1038            RequestBodyBuilder::new()
1039                .description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
1040                .content(
1041                    "multipart/form-data",
1042                    Content::new(Some(RefOr::T(Schema::Object(
1043                        ObjectBuilder::new()
1044                            .schema_type(SchemaType::new(Type::Object))
1045                            .property(
1046                                "file",
1047                                Schema::Object(
1048                                    ObjectBuilder::new()
1049                                        .schema_type(SchemaType::new(Type::String))
1050                                        .format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
1051                                            utoipa::openapi::schema::KnownFormat::Binary,
1052                                        )))
1053                                        .description(Some("ZIP file (manifest.json + config JSONs)"))
1054                                        .into(),
1055                                ),
1056                            )
1057                            .into(),
1058                    )))),
1059                )
1060                .required(Some(Required::True))
1061                .build(),
1062        ))
1063        .responses(
1064            ResponsesBuilder::new()
1065                .response("200", Response::new("OK"))
1066                .response("400", Response::new("Bad Request"))
1067                .build(),
1068        )
1069        .build();
1070    let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
1071    builder = builder.path(install_path, install_item.build());
1072
1073    let uninstall_path = format!("{}/config/package/{{packageId}}", base);
1074    let uninstall_op = OperationBuilder::new()
1075        .summary(Some("Uninstall package"))
1076        .description(Some(
1077            "Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
1078        ))
1079        .operation_id(Some("config_uninstall_package"))
1080        .parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
1081        .responses(
1082            ResponsesBuilder::new()
1083                .response("200", Response::new("OK"))
1084                .response("404", Response::new("Not Found"))
1085                .build(),
1086        )
1087        .build();
1088    let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
1089    builder = builder.path(uninstall_path, uninstall_item.build());
1090
1091    let config_kinds = [
1092        ("schemas", "Schema definitions"),
1093        ("enums", "Enum types"),
1094        ("tables", "Table definitions"),
1095        ("columns", "Column definitions"),
1096        ("indexes", "Index definitions"),
1097        ("relationships", "Relationship definitions"),
1098        ("api_entities", "API entity definitions"),
1099        ("kv_stores", "KV store definitions"),
1100    ];
1101    for (kind, description) in config_kinds {
1102        let path = format!("{}/config/{}", base, kind);
1103        let get_op = OperationBuilder::new()
1104            .summary(Some(format!("Get {}", kind)))
1105            .description(Some(format!(
1106                "Get {} (from _sys_{}). {}",
1107                description, kind, "X-Tenant-ID required."
1108            )))
1109            .operation_id(Some(format!("config_get_{}", kind)))
1110            .parameters(Some(vec![x_tenant_id_header()]))
1111            .responses(default_responses().build())
1112            .build();
1113        let post_body = RequestBodyBuilder::new()
1114            .description(Some(format!("JSON array of {} records.", description)))
1115            .content(
1116                "application/json",
1117                Content::new(Some(RefOr::T(Schema::Array(
1118                    utoipa::openapi::schema::ArrayBuilder::new()
1119                        .items(RefOr::T(json_object_schema()))
1120                        .into(),
1121                )))),
1122            )
1123            .required(Some(Required::True))
1124            .build();
1125        let post_op = OperationBuilder::new()
1126            .summary(Some(format!("Replace {}", kind)))
1127            .description(Some(format!(
1128                "Replace {} for the default package. Runs migrations when rows change.",
1129                kind
1130            )))
1131            .operation_id(Some(format!("config_post_{}", kind)))
1132            .parameters(Some(vec![x_tenant_id_header()]))
1133            .request_body(Some(post_body))
1134            .responses(default_responses().build())
1135            .build();
1136        let item = PathItemBuilder::new()
1137            .operation(HttpMethod::Get, get_op)
1138            .operation(HttpMethod::Post, post_op);
1139        builder = builder.path(path, item.build());
1140    }
1141    builder
1142}
1143
1144/// Build full OpenAPI spec for entity APIs: default model paths plus package-scoped paths
1145/// with concrete package ids, plus KV paths with {namespace}/{key} per package.
1146pub fn build_spec(
1147    default_model: &ResolvedModel,
1148    base_path: &str,
1149    package_models: &HashMap<String, ResolvedModel>,
1150    package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
1151) -> OpenApi {
1152    let server = build_server();
1153    let mut builder = PathsBuilder::new();
1154    builder = add_config_paths(builder, base_path);
1155    builder = add_entity_paths(builder, base_path, default_model, false, None);
1156    for (package_id, model) in package_models {
1157        if !model.entities.is_empty() {
1158            builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
1159        }
1160    }
1161    builder = add_kv_paths(builder, base_path, package_kv_stores);
1162    let paths = builder.build();
1163    OpenApiBuilder::new()
1164        .info(
1165            Info::builder()
1166                .title("Architect API")
1167                .version(env!("CARGO_PKG_VERSION"))
1168                .description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
1169                .build(),
1170        )
1171        .servers(Some(vec![server]))
1172        .paths(paths)
1173        .build()
1174}
1175
1176/// GET /spec — return OpenAPI JSON for entity APIs. Default (unprefixed) routes come from
1177/// state.model; package-scoped routes are built by listing _sys_packages and loading each
1178/// package's config from _sys_* tables (same source of truth as runtime routes).
1179pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
1180    let default_model = state.model.read().expect("model read lock").clone();
1181    let base_path = "/api/v1";
1182
1183    let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
1184    let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
1185    let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
1186    for package_id in package_ids {
1187        if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
1188            if let Ok(model) = resolve(&config) {
1189                package_models.insert(package_id.clone(), model);
1190            }
1191            package_kv_stores.insert(package_id, config.kv_stores);
1192        }
1193    }
1194
1195    let spec = build_spec(
1196        &default_model,
1197        base_path,
1198        &package_models,
1199        &package_kv_stores,
1200    );
1201    Json(spec)
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207    use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
1208    use std::collections::{HashMap, HashSet};
1209
1210    fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
1211        ResolvedEntity {
1212            table_id: seg.to_string(),
1213            schema_name: "public".into(),
1214            table_name: seg.to_string(),
1215            path_segment: seg.to_string(),
1216            pk_columns: vec!["id".into()],
1217            pk_type: PkType::Uuid,
1218            columns: vec![],
1219            operations: vec![
1220                "read".into(),
1221                "create".into(),
1222                "update".into(),
1223                "delete".into(),
1224            ],
1225            sensitive_columns: HashSet::new(),
1226            includes: vec![],
1227            validation: HashMap::new(),
1228            events: vec![],
1229            archive_field: None,
1230            package_id: "_default".into(),
1231            audit_log: false,
1232            global: false,
1233            parent_ref_column: None,
1234            versioning: None,
1235            mcp: None,
1236            extensible_columns,
1237        }
1238    }
1239
1240    #[test]
1241    fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
1242        let model = ResolvedModel {
1243            entities: vec![
1244                entity("products", vec!["attributes".into()]),
1245                entity("orders", vec![]),
1246            ],
1247            entity_by_path: HashMap::new(),
1248            reports: HashMap::new(),
1249        };
1250        let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
1251        let json = serde_json::to_string(&spec).expect("serialize spec");
1252
1253        // The extensible entity exposes both admin paths.
1254        assert!(json.contains("/api/v1/products/extensible-fields"));
1255        assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
1256        // The non-extensible entity does not.
1257        assert!(!json.contains("/api/v1/orders/extensible-fields"));
1258    }
1259
1260    #[test]
1261    fn spec_lists_package_scoped_extensible_field_paths() {
1262        let default_model = ResolvedModel {
1263            entities: vec![entity("products", vec!["attributes".into()])],
1264            entity_by_path: HashMap::new(),
1265            reports: HashMap::new(),
1266        };
1267        let mut package_models = HashMap::new();
1268        package_models.insert(
1269            "billing".to_string(),
1270            ResolvedModel {
1271                entities: vec![entity("invoices", vec!["meta".into()])],
1272                entity_by_path: HashMap::new(),
1273                reports: HashMap::new(),
1274            },
1275        );
1276        let spec = build_spec(&default_model, "/api/v1", &package_models, &HashMap::new());
1277        let json = serde_json::to_string(&spec).expect("serialize spec");
1278
1279        // Package-scoped admin paths are emitted for the package's extensible entity.
1280        assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields"));
1281        assert!(json.contains("/api/v1/package/billing/invoices/extensible-fields/indexes"));
1282    }
1283}