All files apply-options-defaults.ts

98% Statements 49/50
95.34% Branches 41/43
100% Functions 9/9
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96                                        12x 12x 12x   11x 11x 11x 8x 1x       11x 11x 15x           11x   6x 6x 1x         1x     5x 11x 11x 11x   11x 6x 2x 2x   4x     5x 2x     5x 3x       15x   6x 15x 15x 15x   5x       4x             4x 4x    
/**
 * Normalization pass — merges options-level request/response headers into each operation's AST
 * so downstream codegen plugins remain unaware of the options-vs-operation distinction.
 *
 * Runs after parsing and before validation. Mutates the root in place.
 *
 * Merge rules:
 * - Request headers: applied to every operation. Op-level headers with the same name win.
 *   If the op declares `headers: none`, the merge is skipped. If the op uses a referenced
 *   or compound type for headers (rather than inline params), the merge is skipped with a warning.
 * - Response headers: applied to every status code on every operation, regardless of body
 *   presence or status class. Per-status `headers: none` skips the merge for that code.
 *   Per-status header with same name wins.
 */
import type { CkRootNode, OpOperationNode, OpParamNode, OpResponseHeaderNode, OpResponseNode, OpRootNode } from './ast.js';
import type { DiagnosticCollector } from './diagnostics.js';
 
type RootWithGlobals = Pick<CkRootNode, 'file' | 'routes' | 'requestHeaders' | 'responseHeaders'> | Pick<OpRootNode, 'file' | 'routes' | 'requestHeaders' | 'responseHeaders'>;
 
export function applyOptionsDefaults(root: RootWithGlobals, diag: DiagnosticCollector): void {
    const reqGlobals = root.requestHeaders ?? [];
    const resGlobals = root.responseHeaders ?? [];
    if (reqGlobals.length === 0 && resGlobals.length === 0) return;
 
    for (const route of root.routes) {
        const pathParams = new Set([...route.path.matchAll(/\{(\w+)\}/g)].map(m => m[1]!));
        for (const g of reqGlobals) {
            if (pathParams.has(g.name)) {
                diag.error(root.file, route.loc.line, `Global request header '${g.name}' collides with path parameter on '${route.path}'`);
            }
        }
 
        for (const op of route.operations) {
            mergeRequestHeaders(op, reqGlobals, root.file, diag);
            for (const res of op.responses) mergeResponseHeaders(res, resGlobals);
        }
    }
}
 
function mergeRequestHeaders(op: OpOperationNode, globals: OpResponseHeaderNode[], file: string, diag: DiagnosticCollector): void {
    if (globals.length === 0 || op.requestHeadersOptOut) return;
 
    const src = op.headers;
    if (src && (src.kind === 'ref' || src.kind === 'type')) {
        diag.warn(
            file,
            op.loc.line,
            `Operation uses a referenced headers type — global request headers from options are not merged. Inline the headers or use 'headers: none' to silence.`,
        );
        return;
    }
 
    const existing: OpParamNode[] = src?.kind === 'params' ? src.nodes : [];
    const existingNames = new Set(existing.map(p => p.name));
    const additions: OpParamNode[] = [];
    const overridden: string[] = [];
 
    for (const g of globals) {
        if (existingNames.has(g.name)) {
            overridden.push(g.name);
            continue;
        }
        additions.push(headerToParam(g, op));
    }
 
    if (overridden.length > 0) {
        diag.warn(file, op.loc.line, `Operation overrides global request header${overridden.length > 1 ? 's' : ''} ${overridden.map(n => `'${n}'`).join(', ')}`);
    }
 
    if (additions.length === 0 && src) return;
    op.headers = { kind: 'params', nodes: [...additions, ...existing] };
}
 
function mergeResponseHeaders(res: OpResponseNode, globals: OpResponseHeaderNode[]): void {
    if (globals.length === 0 || res.headersOptOut) return;
 
    const existing = res.headers ?? [];
    const existingNames = new Set(existing.map(h => h.name));
    const additions = globals.filter(g => !existingNames.has(g.name));
    if (additions.length === 0 && res.headers) return;
 
    res.headers = [...additions, ...existing];
}
 
function headerToParam(h: OpResponseHeaderNode, op: OpOperationNode): OpParamNode {
    const param: OpParamNode = {
        name: h.name,
        optional: h.optional,
        nullable: false,
        type: h.type,
        loc: op.loc,
    };
    Iif (h.description) param.description = h.description;
    return param;
}