The normalize operation reshapes OpenAPI spec files in place so they can be consumed by stackql's relational analyzer. It is a pure client-side lowering step - upstream server behaviour is not changed, only the local spec representation.
stackql is a relational backend, so polymorphism (oneOf / anyOf) cannot be represented as distinct SQL columns, and opaque type: object schemas with no defined fields have nothing for DESCRIBE to project. The normalize operation applies the following passes that collapse these shapes into something the analyzer can handle:
renameVariants(top-level scope only) - rewritesoneOf/anyOftoallOfat top-level component schemas, their direct properties, and request / response body schemas and their direct properties. Deeper polymorphism is left alone because the analyzer does not descend into it.stripMisplacedSchemaKeywords(whole-doc walk) - in anyproperties:map, deletes children whose name is a schema keyword (type,required,description,title,format,minItems,maxItems,minimum,maximum,default,nullable,readOnly,writeOnly,deprecated,additionalProperties,patternProperties) AND whose value is a scalar. These are upstream indentation bugs where e.g. the outer schema'stype: objectwas indented one level too deep and landed insidepropertiesas if it were a property named "type". Properties whose value is a proper schema object are preserved.convertOpaqueObjectsToStrings(whole-doc walk) - any schema withtype: objectand none of{properties, additionalProperties, patternProperties, allOf, oneOf, anyOf, $ref}is rewritten totype: stringwith(opaque JSON object)appended to the description. stackql then exposes it as a queryable JSON-blob column instead of failingDESCRIBEwith "No columns found".liftPathItemParameters- merges path-item-levelparametersinto each operation's ownparametersarray, deduplicated by(name, in)with operation-level winning per the OpenAPI 3 spec. stackql's request builder reads only operation-level params, so without this lift shared path templates would be left unbound.stripNonRootServers- removes anyservers:block declared at path-item or operation level. The document-levelservers:array is left untouched. stackql's request builder runs against a single base URL per provider; per-op overrides frequently encode placeholder hostnames or per-deployment endpoints that DNS-fail when dialled literally.wrapBareArrayResponses- rewrites operations whose 2xx response is a top-leveltype: arrayinto an object envelope{ <wrapperKey>: [...] }and synthesises the matching wrapper schema incomponents.schemas. Marks the operation withx-stackql-bare-array-wrapso thegeneratestep can emit atransform.bodyandobjectKeyon the resource method. Without this, stackql's row projector has no enclosing key to latch onto andSELECTreturns 0 rows even when the API responds with a non-empty array. See Bare-array Response Wrapping for naming heuristics and override file shape.walkAllOf/flattenAllOf(whole-doc walk) - flattens everyallOfarray by merging members (resolving$refs, deep-cloning) into a single schema.requiredarrays are unioned;propertiesmerge without overwriting.$refcycles are handled via aseenRefsset.
The operation rewrites every .yaml / .yml file in the target directory in place.
async function normalize(options) {
// Implementation details
}| Parameter | Type | Required | Description |
|---|---|---|---|
apiDir |
string | Yes | Directory containing .yaml / .yml OpenAPI service files |
verbose |
boolean | No | Whether to log per-pass detail for every file (default: false) |
bareArrayOverrides |
Object | No | Per-operationId override map for the bare-array wrap pass. Keys are operationIds; values are { wrapperKey?, columnName? }. See Bare-array Response Wrapping. |
The function returns a Promise that resolves to an aggregate stats object across every processed file:
{
allOfFlattened: number, // count of allOf arrays merged
oneOfRenamed: number, // count of oneOf -> allOf rewrites
anyOfRenamed: number, // count of anyOf -> allOf rewrites
stripped: Array<string>, // JSON-pointer-ish paths of stripped keywords
opaqueConverted: Array<string>, // paths of object->string rewrites
pathParamsLifted: number, // count of path-item params merged onto operations
serversStripped: number, // count of non-root `servers:` blocks removed
bareArrayWrapped: number, // count of bare-array 2xx responses wrapped into envelopes
filesProcessed: number // how many files were rewritten
}import { providerdev } from '@stackql/provider-utils';
async function normalizeExample() {
try {
const stats = await providerdev.normalize({
apiDir: './provider-dev/source',
verbose: true,
});
console.log(`Processed ${stats.filesProcessed} file(s).`);
console.log(`Flattened ${stats.allOfFlattened} allOf array(s).`);
console.log(`Renamed ${stats.oneOfRenamed} oneOf / ${stats.anyOfRenamed} anyOf to allOf.`);
console.log(`Stripped ${stats.stripped.length} misplaced schema keyword(s).`);
console.log(`Converted ${stats.opaqueConverted.length} opaque object schema(s) to string.`);
} catch (error) {
console.error('Error normalizing specs:', error);
}
}
normalizeExample();The operation is also exposed as a CLI via the provider-dev-utils bin entry:
npx provider-dev-utils normalize --api-dir ./provider-dev/source [--verbose]| Flag | Required | Description |
|---|---|---|
--api-dir DIR |
Yes | Directory containing .yaml / .yml files to rewrite in place |
--verbose |
No | Log per-pass detail for every file |
--bare-array-overrides JSON|FILE.json |
No | Per-operationId override map for the bare-array wrap pass. Inline JSON or a path to a JSON file. |
Run normalize on the output of providerdev.split (or on any hand-authored service spec directory) before providerdev.generate. Typical pipeline:
providerdev.split-> per-service YAML files inprovider-dev/source/providerdev.normalize-> rewrite those files in place for relational consumptionproviderdev.analyze-> produce mapping recommendationsproviderdev.generate-> build the stackql provider
OpenAPI 3 lets a 2xx response declare its schema as a top-level type: array. stackql's row projector needs an object-with-array shape (e.g. { data: [...] }) so each item becomes a row and the enclosing key acts as the objectKey. Pass 6 detects every such operation, synthesises a wrapper schema, and rewrites the response.
Wrapper schema name: <PascalOpId>Response (e.g. listContexts -> ListContextsResponse).
Wrapper key (the property name under the wrapper that holds the array): snake-cased operationId with leading verbs stripped (list, get, getAll, fetch, find, index, enumerate, retrieve, plus the all-prefixed variants list_all / get_all / fetch_all / find_all). Falls back to items if the result is empty.
Examples:
| operationId | wrapper key |
|---|---|
listContexts |
contexts |
getKekNames |
kek_names |
getAllUsers |
users |
list |
items (fallback) |
Column name (only used when items are scalar): pluralize.singular(<wrapperKey>). Falls back to value.
| Items | Wrapper schema produced |
|---|---|
type: string / integer / number / boolean |
Each runtime value is wrapped into a single-column object row ({ <columnName>: <value> }). |
Object schema (type: object, has properties, $ref, or polymorphism) |
Original items: schema is preserved verbatim under the wrapper key. |
When the heuristic produces a wrapper key or column name you don't like, supply an override file via --bare-array-overrides. Keys are operationIds; values may set wrapperKey and/or columnName:
{
"listContexts": { "wrapperKey": "rows", "columnName": "name" },
"listKekNames": { "wrapperKey": "kek_names" }
}When pass 6 wraps an operation it adds an x-stackql-bare-array-wrap extension to the operation. providerdev.generate reads this marker, attaches a Go-template transform.body and objectKey to the resource method, and strips the marker so it doesn't persist in the written spec. The wrap is authoritative: it overrides any manifest-supplied objectKey for the affected method.
- In-place rewrite: the original files are overwritten. Keep the raw spec under
provider-dev/downloaded/(or equivalent) so you can re-split if needed. - Lossy for opaque objects: pass 3 trades structural detail for a queryable column. The original shape is not recoverable from the normalized file.
- Shallow variant rename: pass 1 only rewrites
oneOf/anyOfat the sites the analyzer reads. Variants buried inside array items or nested properties are preserved on purpose - collapsing them would risk lossy merges for no analyzer benefit. - Lossy for non-root
servers:: pass 5 collapses path-item / operationservers:overrides to the document-level value. If a real provider needs per-op routing (rare in stackql's execution model), don't run normalize on it. - YAML output formatting: files are written with
js-yamlusing{ lineWidth: -1, noRefs: true }, which matches the existing provider-devflatten.mjsoutput but may reorder keys relative to the input.