LCOV - code coverage report
Current view: top level - dashql-app/src/connections/clfn - clfn_request.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 95.0 % 181 172
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import { ClientFunctionAttribute, ClientFunctionError, ClientFunction, ClientFunctionSchema, ClientFunctionSpec } from './clfn_spec.js';
       2              : 
       3              : /// The options that the runtime applies itself, they are never sent
       4            1 : export const STANDARD_OPTIONS = ['_next', '_pages', '_poll', '_wait'] as const;
       5              : 
       6              : /// A request of a function, before a binding resolves its host
       7              : export interface ClientFunctionRequest {
       8              :     /// The HTTP method
       9              :     method: string;
      10              :     /// The name of the host in the spec
      11              :     host: string;
      12              :     /// The path below the host, with its base path and placeholders filled in. A followed URL has none.
      13              :     path: string;
      14              :     /// The query parameters, not encoded
      15              :     query: [string, string][];
      16              :     /// The headers of options, not those of the binding
      17              :     headers: [string, string][];
      18              :     /// The body
      19              :     body: string | null;
      20              :     /// The URL to follow instead of the path and query
      21              :     followUrl: string | null;
      22              : }
      23              : 
      24              : /// The attribute of a name
      25            1 : function findAttribute(attributes: ClientFunctionAttribute[] | undefined, name: string): ClientFunctionAttribute | undefined {
      26            1 :     return attributes?.find(a => a.name === name);
      27            1 : }
      28              : 
      29              : /// The attribute of a name in a schema. A name means the same in all variants of a union, so any of them can answer.
      30            1 : function findSchemaAttribute(spec: ClientFunctionSpec, schema: ClientFunctionSchema | undefined, name: string): ClientFunctionAttribute | undefined {
      31            1 :     const own = findAttribute(schema?.attributes, name);
      32            1 :     if (own !== undefined) {
      33            1 :         return own;
      34              :     }
      35            1 :     for (const variant of schema?.variants ?? []) {
      36            1 :         const attribute = findAttribute(spec.schemas?.[variant]?.attributes, name);
      37            1 :         if (attribute !== undefined) {
      38            1 :             return attribute;
      39              :         }
      40            1 :     }
      41              :     return undefined;
      42            1 : }
      43              : 
      44              : /// A value in the spelling of the API
      45            1 : function toApiValue(spec: ClientFunctionSpec, value: unknown, attribute: ClientFunctionAttribute | undefined, map: boolean = attribute?.map === true): unknown {
      46            1 :     if (Array.isArray(value)) {
      47            1 :         return value.map(item => toApiValue(spec, item, attribute, map));
      48              :     }
      49            1 :     if (typeof value !== 'object' || value === null) {
      50            1 :         return value;
      51              :     }
      52              :     // The keys of a map stay as written, the attribute holds for its values
      53            1 :     if (map) {
      54            1 :         return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toApiValue(spec, item, attribute, false)]));
      55              :     }
      56            1 :     const schema = attribute?.schema !== undefined ? spec.schemas?.[attribute.schema] : undefined;
      57            1 :     const out: Record<string, unknown> = {};
      58            1 :     for (const [name, field] of Object.entries(value)) {
      59            1 :         const fieldAttribute = findSchemaAttribute(spec, schema, name);
      60            1 :         out[fieldAttribute?.api_name ?? name] = toApiValue(spec, field, fieldAttribute);
      61            1 :     }
      62            1 :     return out;
      63            1 : }
      64              : 
      65              : /// A scalar in a path, a header, a query string or a form. Anything else would be sent as `null` or `[object Object]`.
      66            1 : function scalarText(name: string, value: unknown): string {
      67            1 :     if (value === null || value === undefined || typeof value === 'object') {
      68            1 :         throw new ClientFunctionError(`the option ${name} takes a string or a number here`);
      69              :     }
      70            1 :     return String(value);
      71            1 : }
      72              : 
      73              : /// Flatten a value to `a[b][0]=1` pairs, a null is left out
      74            1 : function flatten(name: string, value: unknown, out: [string, string][]): void {
      75            1 :     if (value === null || value === undefined) {
      76            0 :         return;
      77              :     }
      78            1 :     if (Array.isArray(value)) {
      79            1 :         value.forEach((item, i) => flatten(`${name}[${i}]`, item, out));
      80            1 :     } else if (typeof value === 'object') {
      81            1 :         for (const [key, field] of Object.entries(value)) {
      82            1 :             flatten(`${name}[${key}]`, field, out);
      83              :         }
      84              :     } else {
      85            1 :         out.push([name, scalarText(name, value)]);
      86              :     }
      87            1 : }
      88              : 
      89              : /// Add a query option in its style
      90            1 : function addQueryOption(name: string, value: unknown, style: ClientFunctionAttribute['style'], out: [string, string][]): void {
      91            1 :     if (value === null || value === undefined) {
      92            0 :         return;
      93              :     }
      94            1 :     if (Array.isArray(value)) {
      95            1 :         if (style === 'deep') {
      96            0 :             flatten(name, value, out);
      97            1 :         } else if (style === 'csv') {
      98            1 :             out.push([name, value.map(item => scalarText(name, item)).join(',')]);
      99              :         } else {
     100            1 :             value.forEach(item => out.push([name, scalarText(name, item)]));
     101              :         }
     102            1 :     } else if (typeof value === 'object') {
     103            1 :         flatten(name, value, out);
     104              :     } else {
     105            1 :         out.push([name, scalarText(name, value)]);
     106              :     }
     107            1 : }
     108              : 
     109              : /// Encode a value of a path. It can't reach another endpoint: a URL resolves a `.` or `..` segment and percent-encoding
     110              : /// leaves those as they are, so they are errors, and so is an empty value, which would reach the parent. A multi-segment
     111              : /// value is encoded per segment.
     112            1 : function encodePathValue(name: string, value: unknown, multiSegment: boolean): string {
     113            1 :     const text = scalarText(name, value);
     114            1 :     const segments = multiSegment ? text.split('/') : [text];
     115            1 :     if (text === '' || segments.some(s => s === '.' || s === '..')) {
     116            1 :         throw new ClientFunctionError(`the path option ${name} would reach another endpoint`);
     117              :     }
     118            1 :     return segments.map(encodeURIComponent).join('/');
     119            1 : }
     120              : 
     121              : /// Fill in `{name}` placeholders with text from a lookup
     122            1 : function fillPlaceholders(template: string, lookup: (name: string) => string | undefined, describe: (name: string) => string): string {
     123            1 :     return template.replace(/\{([^{}]+)\}/g, (_, name: string) => {
     124            1 :         const value = lookup(name);
     125            1 :         if (value === undefined) {
     126            0 :             throw new ClientFunctionError(describe(name));
     127              :         }
     128            1 :         return value;
     129            1 :     });
     130            1 : }
     131              : 
     132              : /// The base path of a host
     133            1 : function basePath(spec: ClientFunctionSpec, host: string, variables: Record<string, string>): string {
     134            1 :     const declared = spec.hosts?.[host];
     135            1 :     if (declared === undefined) {
     136            1 :         throw new ClientFunctionError(`${spec.namespace} has no host ${host}`);
     137              :     }
     138            1 :     return fillPlaceholders(declared.base_path ?? '', name => variables[name], name => `the host ${host} needs the variable ${name}`);
     139            1 : }
     140              : 
     141              : /// The request of the escape hatch. Its host is a name of the spec and its path is relative, so the security rules hold.
     142            1 : function buildRawRequest(spec: ClientFunctionSpec, fn: ClientFunction, options: Record<string, unknown>, variables: Record<string, string>): ClientFunctionRequest {
     143            1 :     const { method, path, host, body, ...rest } = options;
     144            1 :     if (typeof method !== 'string' || typeof path !== 'string') {
     145            0 :         throw new ClientFunctionError(`${spec.namespace}.${fn.name} needs the options method and path`);
     146              :     }
     147              :     // A URL reads `%2e` as a dot and a backslash as a slash. A query in a path would be sent as part of the path.
     148            1 :     if (!path.startsWith('/') || path.startsWith('//') || /[\\?#]/.test(path) || path.split('/').some(s => /^(\.|%2e){1,2}$/i.test(s))) {
     149            1 :         throw new ClientFunctionError(`the path of ${spec.namespace}.${fn.name} must be relative to its host, and any other option goes to its query`);
     150              :     }
     151            1 :     const hostName = typeof host === 'string' ? host : fn.host;
     152            1 :     if (hostName === undefined) {
     153            0 :         throw new ClientFunctionError(`${spec.namespace}.${fn.name} needs the option host`);
     154              :     }
     155            1 :     const query: [string, string][] = [];
     156            1 :     for (const [name, value] of Object.entries(rest)) {
     157            1 :         addQueryOption(name, value, undefined, query);
     158              :     }
     159            1 :     return {
     160            1 :         method: method.toUpperCase(),
     161            1 :         host: hostName,
     162            1 :         path: basePath(spec, hostName, variables) + path,
     163            1 :         query,
     164            1 :         headers: body === undefined ? [] : [['Content-Type', 'application/json']],
     165            1 :         body: body === undefined ? null : JSON.stringify(body),
     166            1 :         followUrl: null,
     167            1 :     };
     168            1 : }
     169              : 
     170              : /// Build the request of a call. The standard options are ignored.
     171            1 : export function buildClientFunctionRequest(spec: ClientFunctionSpec, fn: ClientFunction, options: Record<string, unknown>, variables: Record<string, string>): ClientFunctionRequest {
     172            1 :     if (fn.raw) {
     173            1 :         const rest = Object.fromEntries(Object.entries(options).filter(([name]) => !(STANDARD_OPTIONS as readonly string[]).includes(name)));
     174            1 :         return buildRawRequest(spec, fn, rest, variables);
     175              :     }
     176            1 :     if (fn.host === undefined || fn.path === undefined) {
     177            0 :         throw new ClientFunctionError(`${spec.namespace}.${fn.name} has no host or path`);
     178              :     }
     179            1 :     const hasBody = fn.body !== undefined || (fn.options ?? []).some(o => o.in?.startsWith('body'));
     180            1 :     const pathValues = new Map<string, string>();
     181            1 :     const query: [string, string][] = [];
     182            1 :     const headers: [string, string][] = [];
     183            1 :     const bodyFields: Record<string, unknown> = {};
     184            1 :     let rootBody: unknown = undefined;
     185              : 
     186            1 :     for (const [name, value] of Object.entries(options)) {
     187            1 :         if ((STANDARD_OPTIONS as readonly string[]).includes(name)) {
     188            1 :             continue;
     189              :         }
     190            1 :         const attribute = findAttribute(fn.options, name);
     191            1 :         const apiName = attribute?.api_name ?? name;
     192              :         // A name the spec doesn't list goes to the body if the function has one
     193            1 :         const location = attribute?.in ?? (hasBody ? 'body' : 'query');
     194            1 :         if (location === 'path') {
     195            1 :             pathValues.set(apiName, encodePathValue(name, value, attribute?.multi_segment ?? false));
     196            1 :         } else if (location === 'query') {
     197            1 :             addQueryOption(apiName, toApiValue(spec, value, attribute), attribute?.style, query);
     198            1 :         } else if (location === 'header') {
     199              :             // A null leaves the header out, as on a followed URL
     200            1 :             if (value !== null) headers.push([apiName, scalarText(name, value)]);
     201            1 :         } else if (location === 'body_root') {
     202            1 :             rootBody = toApiValue(spec, value, attribute);
     203            1 :         } else if (location === 'body') {
     204            1 :             bodyFields[apiName] = toApiValue(spec, value, attribute);
     205              :         } else {
     206            0 :             throw new ClientFunctionError(`${spec.namespace}.${fn.name}: the option ${name} has the unknown location ${location}`);
     207              :         }
     208            1 :     }
     209              : 
     210            1 :     const path = fillPlaceholders(fn.path, name => pathValues.get(name), name => `${spec.namespace}.${fn.name} needs the path value ${name}`);
     211            1 :     let body: string | null = null;
     212            1 :     const format = fn.body ?? 'json';
     213            1 :     const hasFields = Object.keys(bodyFields).length > 0;
     214            1 :     if (rootBody !== undefined || hasFields) {
     215            1 :         if (format === 'json') {
     216            1 :             headers.push(['Content-Type', 'application/json']);
     217            1 :             body = JSON.stringify(rootBody !== undefined ? rootBody : bodyFields);
     218            1 :         } else if (format === 'form') {
     219            1 :             const pairs: [string, string][] = [];
     220            1 :             for (const [name, value] of Object.entries(bodyFields)) {
     221            1 :                 flatten(name, value, pairs);
     222              :             }
     223            1 :             headers.push(['Content-Type', 'application/x-www-form-urlencoded']);
     224            1 :             body = new URLSearchParams(pairs).toString();
     225            1 :         } else {
     226              :             // A media type: the body is one string option
     227            1 :             const values = Object.values(bodyFields);
     228            1 :             if (values.length !== 1 || typeof values[0] !== 'string') {
     229            0 :                 throw new ClientFunctionError(`${spec.namespace}.${fn.name} takes a ${format} body as one string`);
     230              :             }
     231            1 :             headers.push(['Content-Type', format]);
     232            1 :             body = values[0];
     233            1 :         }
     234            1 :     }
     235            1 :     return {
     236            1 :         method: (fn.method ?? 'GET').toUpperCase(),
     237            1 :         host: fn.host,
     238            1 :         path: basePath(spec, fn.host, variables) + path,
     239            1 :         query,
     240            1 :         headers,
     241            1 :         body,
     242            1 :         followUrl: null,
     243            1 :     };
     244            1 : }
     245              : 
     246              : /// A request that follows a URL from a response: always a GET without a body, so it can't repeat a create. The headers of
     247              : /// options still apply, the path and query come from the URL.
     248            1 : export function followRequest(request: ClientFunctionRequest, url: string): ClientFunctionRequest {
     249            1 :     const headers = request.headers.filter(([name]) => name.toLowerCase() !== 'content-type');
     250            1 :     return { ...request, method: 'GET', path: '', query: [], headers, body: null, followUrl: url };
     251            1 : }
     252              : 
     253              : /// Resolve a URL from a response or a script. A URL on another origin never receives the token.
     254            1 : export function resolveFollowUrl(baseUrl: URL, url: string): URL {
     255            1 :     const resolved = new URL(url, baseUrl);
     256            1 :     if (resolved.origin !== baseUrl.origin) {
     257            1 :         throw new ClientFunctionError(`the URL ${resolved.origin} is on another origin than ${baseUrl.origin}`);
     258              :     }
     259            1 :     return resolved;
     260            1 : }
     261              : 
     262              : /// The URL of a request on a host
     263            1 : export function requestUrl(request: ClientFunctionRequest, baseUrl: URL): URL {
     264            1 :     if (request.followUrl !== null) {
     265            1 :         return resolveFollowUrl(baseUrl, request.followUrl);
     266              :     }
     267            1 :     const url = new URL(baseUrl);
     268            1 :     url.pathname = baseUrl.pathname.replace(/\/$/, '') + request.path;
     269            1 :     for (const [name, value] of request.query) {
     270            1 :         url.searchParams.append(name, value);
     271              :     }
     272            1 :     return url;
     273            1 : }
        

Generated by: LCOV version 2.0-1