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 : }
|