Packages
oaspec
0.49.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.0
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.1
0.58.0
0.57.0
0.56.0
0.55.0
0.54.0
0.53.0
0.52.0
0.51.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.3
0.6.1
0.6.0
0.5.0
0.4.0
0.3.0
0.1.3
Generate Gleam code from OpenAPI 3.x specifications
Current section
Files
Jump to
Current section
Files
src/oaspec/internal/openapi/capability_check.gleam
import gleam/dict
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import gleam/string
import oaspec/config
import oaspec/internal/capability
import oaspec/internal/codegen/context.{type Context}
import oaspec/internal/openapi/location_index.{type LocationIndex}
import oaspec/internal/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema,
Inline, ObjectSchema, OneOfSchema, Reference, Typed,
}
import oaspec/internal/openapi/spec.{
type HttpMethod, type OpenApiSpec, type Resolved, Value,
}
import oaspec/internal/util/http
import oaspec/openapi/diagnostic.{
type Diagnostic, type SourceLoc, SeverityError, SeverityWarning, TargetBoth,
TargetServer,
}
/// Run capability checks on a resolved spec.
/// Returns errors for unsupported features and warnings for parsed-but-unused features.
///
/// The `index` carries YAML line/column information used to attach
/// `SourceLoc` to each diagnostic (Issue #411). Callers that genuinely
/// have no source-location data (e.g. tests that synthesise a
/// `OpenApiSpec` directly) can pass `location_index.empty()`; every
/// diagnostic will then carry `NoSourceLoc`, matching pre-#411 behaviour.
pub fn check(
spec: OpenApiSpec(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
let schema_errors = check_schemas(spec, index)
let security_errors = check_security_schemes(spec, index)
list.flatten([schema_errors, security_errors])
}
/// Check all schemas for unsupported keywords stored during lossless parse.
/// Covers both component schemas and inline schemas in operations.
fn check_schemas(
spec: OpenApiSpec(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
let component_errors = case spec.components {
None -> []
Some(components) ->
dict.to_list(components.schemas)
|> list.flat_map(fn(entry) {
let #(name, schema_ref) = entry
check_schema_ref("components.schemas." <> name, schema_ref, index)
})
}
let inline_errors = check_inline_schemas(spec, index)
list.append(component_errors, inline_errors)
}
/// Check inline schemas within operations (request bodies, responses, parameters).
fn check_inline_schemas(
spec: OpenApiSpec(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
dict.to_list(spec.paths)
|> list.flat_map(fn(path_entry) {
let #(path, ref_or) = path_entry
case ref_or {
Value(path_item) ->
check_path_item_schemas("paths." <> path, path_item, index)
_ -> []
}
})
}
fn check_path_item_schemas(
base_path: String,
pi: spec.PathItem(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
let method_ops = [
#("get", pi.get),
#("post", pi.post),
#("put", pi.put),
#("delete", pi.delete),
#("patch", pi.patch),
#("head", pi.head),
#("options", pi.options),
#("trace", pi.trace),
]
list.flat_map(method_ops, fn(entry) {
let #(method, maybe_op) = entry
case maybe_op {
Some(op) -> check_operation_schemas(base_path <> "." <> method, op, index)
None -> []
}
})
}
fn check_operation_schemas(
base_path: String,
op: spec.Operation(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
// Check request body schemas
let rb_errors = case op.request_body {
Some(Value(rb)) ->
dict.to_list(rb.content)
|> list.flat_map(fn(entry) {
let #(ct, mt) = entry
case mt.schema {
Some(sr) ->
check_schema_ref(
base_path <> ".requestBody.content." <> ct <> ".schema",
sr,
index,
)
None -> []
}
})
_ -> []
}
// Check response schemas
let resp_errors =
dict.to_list(op.responses)
|> list.flat_map(fn(entry) {
let #(code, ref_or) = entry
case ref_or {
Value(resp) ->
dict.to_list(resp.content)
|> list.flat_map(fn(ct_entry) {
let #(ct, mt) = ct_entry
case mt.schema {
Some(sr) ->
check_schema_ref(
base_path
<> ".responses."
<> http.status_code_to_string(code)
<> ".content."
<> ct
<> ".schema",
sr,
index,
)
None -> []
}
})
_ -> []
}
})
list.append(rb_errors, resp_errors)
}
/// Check a SchemaRef recursively for unsupported keywords.
fn check_schema_ref(
path: String,
ref: SchemaRef,
index: LocationIndex,
) -> List(Diagnostic) {
case ref {
Reference(..) -> []
Inline(schema_obj) -> check_schema(path, schema_obj, index)
}
}
/// Check a single schema and recurse into children.
fn check_schema(
path: String,
schema_obj: SchemaObject,
index: LocationIndex,
) -> List(Diagnostic) {
let metadata = schema.get_metadata(schema_obj)
// Check unsupported keywords stored by lossless parser. Try the
// first offending keyword's exact YAML path (e.g.
// `...WithDefs.$defs`) before falling back to the schema-level loc;
// pointing at the keyword itself is more useful in editors than
// the surrounding schema header.
let keyword_errors = case metadata.unsupported_keywords {
[] -> []
keywords -> {
let keyword_list = string.join(keywords, "', '")
let loc = case keywords {
[first, ..] ->
location_index.lookup_with_ancestor(index, path <> "." <> first)
[] -> location_index.lookup_with_ancestor(index, path)
}
[
diagnostic.capability(
severity: SeverityError,
target: TargetBoth,
path: path,
detail: "Unsupported JSON Schema keyword '"
<> keyword_list
<> "' found. Remove or model differently. See: "
<> string.join(
list.filter_map(keywords, fn(k) {
list.find(capability.registry(), fn(c) { c.name == k })
|> result.map(fn(c) { c.note })
}),
"; ",
),
hint: Some(
"Remove the keyword or restructure the schema using supported constructs.",
),
loc: loc,
),
]
}
}
// Recurse into children
let child_errors = case schema_obj {
ObjectSchema(properties:, additional_properties:, ..) -> {
let prop_errors =
dict.to_list(properties)
|> list.flat_map(fn(e) {
check_schema_ref(path <> ".properties." <> e.0, e.1, index)
})
let ap_errors = case additional_properties {
Typed(ap) ->
check_schema_ref(path <> ".additionalProperties", ap, index)
_ -> []
}
list.append(prop_errors, ap_errors)
}
ArraySchema(items:, ..) -> check_schema_ref(path <> ".items", items, index)
AllOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s) {
check_schema_ref(path <> ".allOf", s, index)
})
OneOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s) {
check_schema_ref(path <> ".oneOf", s, index)
})
AnyOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s) {
check_schema_ref(path <> ".anyOf", s, index)
})
_ -> []
}
list.append(keyword_errors, child_errors)
}
/// Check security schemes for unsupported types (e.g. mutualTLS).
fn check_security_schemes(
spec: OpenApiSpec(Resolved),
index: LocationIndex,
) -> List(Diagnostic) {
case spec.components {
None -> []
Some(components) ->
dict.to_list(components.security_schemes)
|> list.filter_map(fn(entry) {
let #(name, ref_or) = entry
case ref_or {
Value(spec.UnsupportedScheme(scheme_type:)) -> {
let path = "components.securitySchemes." <> name
Ok(diagnostic.capability(
severity: SeverityError,
target: TargetBoth,
path: path,
detail: "Unsupported security scheme type: '"
<> scheme_type
<> "'. Supported types: apiKey, http, oauth2, openIdConnect.",
hint: Some(
"Use apiKey, http (bearer), oauth2, or openIdConnect instead.",
),
loc: location_index.lookup_with_ancestor(index, path),
))
}
_ -> Error(Nil)
}
})
}
}
/// Render a `paths.<url>.<method>` YAML pointer for the AnalyzedOperation
/// the LocationIndex was built from. The index keys URLs verbatim
/// (matching yamerl's literal YAML key text) so no `~1` escaping is
/// needed; method names are folded to lowercase to match the YAML
/// document.
fn operation_yaml_prefix(url: String, method: HttpMethod) -> String {
"paths." <> url <> "." <> string.lowercase(spec.method_to_string(method))
}
/// Translate a capability-check `op_id`-rooted path into a YAML pointer
/// the LocationIndex understands. Unmatched paths (e.g. `webhooks`,
/// `components.*`) pass through unchanged.
fn capability_path_to_yaml(
path: String,
operations: List(context.AnalyzedOperation),
) -> String {
case string.split(path, ".") {
[first, ..rest] ->
case
list.find_map(operations, fn(op) {
let #(op_id, _, url, method) = op
case op_id == first {
True -> Ok(operation_yaml_prefix(url, method))
False -> Error(Nil)
}
})
{
Ok(prefix) ->
case rest {
[] -> prefix
_ -> prefix <> "." <> string.join(rest, ".")
}
// nolint: thrown_away_error -- non-op_id paths (e.g. `webhooks`, `components.*`) pass through unchanged; there is no error to propagate here.
Error(_) -> path
}
[] -> path
}
}
/// Resolve a SourceLoc for a capability-check diagnostic path by
/// translating to a YAML pointer (op_id → paths.<url>.<method>) and
/// falling back to the closest known ancestor when the exact path
/// isn't in the index.
fn lookup_loc(ctx: Context, index: LocationIndex, path: String) -> SourceLoc {
let yaml_path = capability_path_to_yaml(path, context.operations(ctx))
location_index.lookup_with_ancestor(index, yaml_path)
}
/// Check for parsed-but-unused AST features, emitting warnings.
/// This is the single source of truth for "parsed but not generated" diagnostics.
/// Requires Context because some checks iterate over resolved operations.
pub fn check_preserved(ctx: Context, index: LocationIndex) -> List(Diagnostic) {
let webhook_warnings = case dict.is_empty(context.spec(ctx).webhooks) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "webhooks",
detail: "Webhooks are parsed but not used by code generation.",
hint: Some(
"Webhooks will not appear in generated code. No action needed unless you expected them.",
),
loc: location_index.lookup_with_ancestor(index, "webhooks"),
),
]
}
let ops = context.operations(ctx)
let response_warnings =
list.flat_map(ops, fn(op) {
let #(op_id, operation, _path, _method) = op
let entries = dict.to_list(operation.responses)
list.flat_map(entries, fn(entry) {
let #(status_code, ref_or) = entry
case ref_or {
Value(response) -> {
let base_path =
op_id <> ".responses." <> http.status_code_to_string(status_code)
let multi_content_warnings = case
config.mode(context.config(ctx)),
list.length(dict.to_list(response.content))
{
config.Client, _ -> []
_, n if n > 1 -> {
let path = base_path <> ".content"
[
diagnostic.capability(
severity: SeverityWarning,
target: TargetServer,
path: path,
detail: "Multiple response content types are not fully supported for server code generation. Generated server responses lose the content-type header.",
hint: Some(
"Use a single content type per response for full server code generation support.",
),
loc: lookup_loc(ctx, index, path),
),
]
}
_, _ -> []
}
let header_warnings = []
let link_warnings = case dict.is_empty(response.links) {
True -> []
False -> {
let path = base_path <> ".links"
[
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: path,
detail: "Response links are parsed but not used by code generation.",
hint: Some(
"Response links will not appear in generated code. No action needed.",
),
loc: lookup_loc(ctx, index, path),
),
]
}
}
let content_entries = dict.to_list(response.content)
let encoding_warnings =
list.flat_map(content_entries, fn(ce) {
let #(media_type_name, media_type) = ce
case dict.is_empty(media_type.encoding) {
True -> []
False -> {
let path =
base_path <> ".content." <> media_type_name <> ".encoding"
[
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: path,
detail: "MediaType encoding is parsed but not used by code generation.",
hint: Some(
"Encoding settings will not affect generated code. No action needed.",
),
loc: lookup_loc(ctx, index, path),
),
]
}
}
})
list.flatten([
multi_content_warnings,
header_warnings,
link_warnings,
encoding_warnings,
])
}
_ -> []
}
})
})
let request_body_encoding_warnings =
list.flat_map(ops, fn(op) {
let #(op_id, operation, _path, _method) = op
case operation.request_body {
Some(Value(rb)) -> {
let base_path = op_id <> ".requestBody"
let content_entries = dict.to_list(rb.content)
list.flat_map(content_entries, fn(ce) {
let #(media_type_name, media_type) = ce
case dict.is_empty(media_type.encoding) {
True -> []
False -> {
let path =
base_path <> ".content." <> media_type_name <> ".encoding"
[
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: path,
detail: "Request-body encoding is parsed but not used by code generation.",
hint: Some(
"Encoding settings (contentType, style, explode) will not affect generated code. No action needed.",
),
loc: lookup_loc(ctx, index, path),
),
]
}
}
})
}
_ -> []
}
})
let external_docs_warnings = case context.spec(ctx).external_docs {
Some(_) -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "externalDocs",
detail: "External docs are parsed but not used by code generation.",
hint: Some(
"External docs will not appear in generated code. Include documentation in descriptions instead.",
),
loc: location_index.lookup_with_ancestor(index, "externalDocs"),
),
]
None -> []
}
let tag_warnings = case list.is_empty(context.spec(ctx).tags) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "tags",
detail: "Top-level tags are parsed but not used by code generation.",
hint: Some("Tags will not appear in generated code. No action needed."),
loc: location_index.lookup_with_ancestor(index, "tags"),
),
]
}
// Operation-level and path-level server overrides are now supported in client
// code generation (server precedence: operation > path > top-level).
let operation_server_warnings = []
let path_server_warnings = []
let component_warnings = case context.spec(ctx).components {
Some(components) -> {
let header_w = case dict.is_empty(components.headers) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "components.headers",
// Issue #306 wired response headers through to the
// generated router, so component headers `$ref`'d from a
// response.headers entry now reach the wire. Standalone
// component header definitions that aren't referenced by
// any response are still dropped on the floor.
detail: "Component headers are wired into generated code only when referenced from a response.headers entry; standalone definitions are not surfaced.",
hint: Some(
"If a component header should reach a client, reference it from the relevant response.headers entry.",
),
loc: location_index.lookup_with_ancestor(
index,
"components.headers",
),
),
]
}
let example_w = case dict.is_empty(components.examples) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "components.examples",
detail: "Component examples are parsed but not used by code generation.",
hint: Some(
"Component examples will not appear in generated code. Include examples in descriptions instead.",
),
loc: location_index.lookup_with_ancestor(
index,
"components.examples",
),
),
]
}
let link_w = case dict.is_empty(components.links) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "components.links",
detail: "Component links are parsed but not used by code generation.",
hint: Some(
"Component links will not appear in generated code. No action needed.",
),
loc: location_index.lookup_with_ancestor(index, "components.links"),
),
]
}
let callback_w = case dict.is_empty(components.callbacks) {
True -> []
False -> [
diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: "components.callbacks",
detail: "Component callbacks are parsed and preserved but not used by code generation.",
hint: Some(
"Callback endpoints will not appear in generated code. No action needed unless you expected handler stubs for them.",
),
loc: location_index.lookup_with_ancestor(
index,
"components.callbacks",
),
),
]
}
list.flatten([header_w, example_w, link_w, callback_w])
}
None -> []
}
let operation_callback_warnings =
list.filter_map(ops, fn(op) {
let #(op_id, operation, _path, _method) = op
case dict.is_empty(operation.callbacks) {
True -> Error(Nil)
False -> {
let path = op_id <> ".callbacks"
Ok(diagnostic.capability(
severity: SeverityWarning,
target: TargetBoth,
path: path,
detail: "Operation-level callbacks are parsed and preserved but not used by code generation.",
hint: Some(
"The callback endpoints listed here will not appear in generated server/client code. No action needed unless you expected handler stubs.",
),
loc: lookup_loc(ctx, index, path),
))
}
}
})
list.flatten([
webhook_warnings,
response_warnings,
request_body_encoding_warnings,
external_docs_warnings,
tag_warnings,
operation_server_warnings,
path_server_warnings,
component_warnings,
operation_callback_warnings,
])
}