All files / src validate-operation.ts

14.89% Statements 7/47
4.87% Branches 2/41
25% Functions 2/8
17.14% Lines 6/35

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          8x                     8x 8x   8x                                 8x 8x                                                                                                
import type { ContractTypeNode, FieldNode, OpRootNode } from './ast.js';
import type { DiagnosticCollector } from './diagnostics.js';
 
/** Extract `{paramName}` segments from a route path. */
function extractPathParams(path: string): string[] {
    return [...path.matchAll(/\{(\w+)\}/g)].map(m => m[1]!);
}
 
/**
 * Warn when a route path contains `{param}` placeholders that are not
 * explicitly declared in a `params` block; warn on empty/invalid request body
 * declarations and on `application/x-www-form-urlencoded` bodies that contain
 * nested object/array shapes (which don't round-trip cleanly through
 * URL-encoded form encoding).
 */
export function validateOp(root: OpRootNode, diag: DiagnosticCollector): void {
    for (const route of root.routes) {
        const pathParams = extractPathParams(route.path);
 
        Iif (pathParams.length > 0) {
            if (!route.params) {
                for (const name of pathParams) {
                    diag.warn(root.file, route.loc.line, `Path parameter '{${name}}' is not explicitly defined in a params block`);
                }
            } else if (route.params.kind === 'ref' || route.params.kind === 'type') {
                // Type-reference or ContractTypeNode form — all params are covered by the type
            } else {
                const declared = new Set(route.params.nodes.map((p: { name: string }) => p.name));
                for (const name of pathParams) {
                    if (!declared.has(name)) {
                        diag.warn(root.file, route.loc.line, `Path parameter '{${name}}' is not explicitly defined in a params block`);
                    }
                }
            }
        }
 
        for (const op of route.operations) {
            Eif (!op.request) continue;
            if (op.request.bodies.length === 0) {
                diag.warn(root.file, op.loc.line, `Operation has an empty request block — declare at least one content type`);
                continue;
            }
            for (const body of op.request.bodies) {
                if (body.contentType !== 'application/x-www-form-urlencoded') continue;
                if (typeContainsNestedShape(body.bodyType, root)) {
                    diag.warn(
                        root.file,
                        op.loc.line,
                        `application/x-www-form-urlencoded body contains nested objects or arrays — these don't round-trip cleanly through form encoding`,
                    );
                }
            }
        }
    }
}
 
/**
 * True if `type` (resolved through model refs) contains any field whose type is itself
 * an object, inline object, array of object, or record of object.
 */
function typeContainsNestedShape(type: ContractTypeNode, root: OpRootNode, seen: Set<string> = new Set()): boolean {
    const fields = resolveFields(type, root, seen);
    if (!fields) return false;
    return fields.some(f => isNestedShape(f.type));
}
 
function isNestedShape(t: ContractTypeNode): boolean {
    if (t.kind === 'inlineObject') return true;
    if (t.kind === 'array') return t.item.kind === 'inlineObject' || t.item.kind === 'ref' || t.item.kind === 'record';
    if (t.kind === 'record') return true;
    if (t.kind === 'union' || t.kind === 'discriminatedUnion' || t.kind === 'intersection') return true;
    return false;
}
 
function resolveFields(type: ContractTypeNode, root: OpRootNode, seen: Set<string>): FieldNode[] | undefined {
    if (type.kind === 'inlineObject') return type.fields;
    if (type.kind === 'ref') {
        if (seen.has(type.name)) return undefined;
        seen.add(type.name);
        // OpRoot doesn't carry models; if running on a CkRoot the caller would resolve. For pure .op
        // contexts we can't resolve refs — be conservative and return undefined (no warning).
        return undefined;
    }
    return undefined;
}