Skip to content

Commit 4f7d325

Browse files
Merge pull request #32 from crossplane/serve-module
Add a serve module so a function is one function
2 parents eb30363 + c60674c commit 4f7d325

3 files changed

Lines changed: 535 additions & 0 deletions

File tree

src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ export {
6969
startServer,
7070
} from './runtime/runtime.js';
7171

72+
// Entrypoint helpers
73+
export {
74+
type ComposeFunction,
75+
type ComposeResponse,
76+
DEFAULT_ADDRESS,
77+
DEFAULT_TLS_SERVER_CERTS_DIR,
78+
fromCompose,
79+
helpText,
80+
parseArgs,
81+
serve,
82+
type ServeOptions,
83+
} from './serve/serve.js';
84+
7285
// Protocol buffer types
7386
export {
7487
Capability,

src/serve/serve.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
DEFAULT_ADDRESS,
4+
DEFAULT_TLS_SERVER_CERTS_DIR,
5+
fromCompose,
6+
helpText,
7+
parseArgs,
8+
usageErrorText,
9+
type ComposeFunction,
10+
} from './serve.js';
11+
import { Resource, RunFunctionRequest } from '../proto/run_function.js';
12+
import { Severity } from '../proto/run_function.js';
13+
import { fatal, to } from '../response/response.js';
14+
15+
describe('parseArgs', () => {
16+
it('should default every flag', () => {
17+
expect(parseArgs([])).toEqual({
18+
address: DEFAULT_ADDRESS,
19+
debug: false,
20+
insecure: false,
21+
tlsServerCertsDir: DEFAULT_TLS_SERVER_CERTS_DIR,
22+
help: false,
23+
});
24+
});
25+
26+
it('should parse flags given as separate arguments', () => {
27+
const opts = parseArgs([
28+
'--address',
29+
'localhost:1234',
30+
'--tls-server-certs-dir',
31+
'/certs',
32+
'--debug',
33+
'--insecure',
34+
]);
35+
expect(opts).toEqual({
36+
address: 'localhost:1234',
37+
debug: true,
38+
insecure: true,
39+
tlsServerCertsDir: '/certs',
40+
help: false,
41+
});
42+
});
43+
44+
it('should parse flags given as --flag=value', () => {
45+
const opts = parseArgs(['--address=0.0.0.0:9999', '--tls-server-certs-dir=/certs']);
46+
expect(opts.address).toBe('0.0.0.0:9999');
47+
expect(opts.tlsServerCertsDir).toBe('/certs');
48+
});
49+
50+
it('should accept the short debug flag', () => {
51+
expect(parseArgs(['-d']).debug).toBe(true);
52+
});
53+
54+
it('should record whether help was asked for', () => {
55+
expect(parseArgs([]).help).toBe(false);
56+
expect(parseArgs(['--help']).help).toBe(true);
57+
expect(parseArgs(['-h']).help).toBe(true);
58+
});
59+
60+
it('should reject an unrecognised flag', () => {
61+
expect(() => parseArgs(['--nope'])).toThrow(/Unknown option '--nope'/);
62+
});
63+
64+
it('should reject a flag missing its value', () => {
65+
expect(() => parseArgs(['--address'])).toThrow(/argument missing/);
66+
});
67+
68+
it('should reject a value given to a boolean flag', () => {
69+
// The hand-rolled parser this replaced reported this as an unrecognised
70+
// flag, which misdiagnoses it.
71+
expect(() => parseArgs(['--debug=false'])).toThrow(/does not take an argument/);
72+
});
73+
});
74+
75+
describe('helpText', () => {
76+
it('should name the program and list every flag', () => {
77+
const help = helpText('my-function');
78+
expect(help).toContain('Usage: my-function');
79+
for (const flag of ['--address', '--debug', '--insecure', '--tls-server-certs-dir', '--help']) {
80+
expect(help).toContain(flag);
81+
}
82+
});
83+
84+
it('should stay in step with the parser', () => {
85+
// Help is derived from the same flag table the parser uses, so anything
86+
// it lists must parse.
87+
const help = helpText('fn');
88+
const listed = [...help.matchAll(/--([a-z-]+)/g)].map((m) => m[1]);
89+
expect(listed.length).toBeGreaterThan(0);
90+
for (const flag of listed) {
91+
expect(() => parseArgs([`--${flag}`, 'x'])).not.toThrow(/Unknown option/);
92+
}
93+
});
94+
});
95+
96+
describe('usageErrorText', () => {
97+
it('should surface the parser message and point at --help', () => {
98+
let thrown: unknown;
99+
try {
100+
parseArgs(['--nope']);
101+
} catch (error) {
102+
thrown = error;
103+
}
104+
105+
const text = usageErrorText('main.js', thrown);
106+
107+
expect(text).toBe("main.js: Unknown option '--nope'\nTry 'main.js --help' for the available flags.");
108+
});
109+
110+
it('should not leak a stack trace', () => {
111+
const text = usageErrorText('fn', new Error('boom'));
112+
expect(text).not.toContain(' at ');
113+
expect(text.split('\n')).toHaveLength(2);
114+
});
115+
116+
it('should cope with something that is not an Error', () => {
117+
expect(usageErrorText('fn', 'plain string')).toContain('fn: plain string');
118+
});
119+
});
120+
121+
describe('fromCompose', () => {
122+
const request = (): RunFunctionRequest =>
123+
RunFunctionRequest.fromJSON({
124+
observed: {
125+
composite: {
126+
resource: {
127+
apiVersion: 'example.crossplane.io/v1alpha1',
128+
kind: 'Example',
129+
metadata: { name: 'example' },
130+
},
131+
},
132+
},
133+
});
134+
135+
it('should hand the compose function a response with desired already populated', async () => {
136+
const compose: ComposeFunction = (_req, rsp) => {
137+
// The point of ComposeResponse: no non-null assertion needed here.
138+
rsp.desired.resources['vpc'] = Resource.fromJSON({
139+
resource: { apiVersion: 'ec2.aws.upbound.io/v1beta1', kind: 'VPC' },
140+
});
141+
return rsp;
142+
};
143+
144+
const rsp = await fromCompose(compose).RunFunction(request());
145+
146+
expect(rsp.desired?.resources['vpc']?.resource).toEqual({
147+
apiVersion: 'ec2.aws.upbound.io/v1beta1',
148+
kind: 'VPC',
149+
});
150+
});
151+
152+
it('should await an async compose function', async () => {
153+
const compose: ComposeFunction = async (_req, rsp) => {
154+
await Promise.resolve();
155+
rsp.desired.resources['late'] = Resource.fromJSON({ resource: { kind: 'Late' } });
156+
return rsp;
157+
};
158+
159+
const rsp = await fromCompose(compose).RunFunction(request());
160+
161+
expect(Object.keys(rsp.desired?.resources ?? {})).toContain('late');
162+
});
163+
164+
it('should carry through results the compose function adds', async () => {
165+
const compose: ComposeFunction = (_req, rsp) => {
166+
fatal(rsp, 'nope');
167+
return rsp;
168+
};
169+
170+
const rsp = await fromCompose(compose).RunFunction(request());
171+
172+
expect(rsp.results).toHaveLength(1);
173+
expect(rsp.results[0]?.severity).toBe(Severity.SEVERITY_FATAL);
174+
expect(rsp.results[0]?.message).toBe('nope');
175+
});
176+
177+
it('should preserve desired state accumulated by earlier functions', async () => {
178+
const req = RunFunctionRequest.fromJSON({
179+
desired: {
180+
resources: {
181+
existing: { resource: { kind: 'Existing' } },
182+
},
183+
},
184+
});
185+
186+
const compose: ComposeFunction = (_req, rsp) => {
187+
rsp.desired.resources['added'] = Resource.fromJSON({ resource: { kind: 'Added' } });
188+
return rsp;
189+
};
190+
191+
const rsp = await fromCompose(compose).RunFunction(req);
192+
193+
expect(Object.keys(rsp.desired?.resources ?? {}).sort()).toEqual(['added', 'existing']);
194+
});
195+
196+
it('should use the response the compose function returns, not the one it was given', async () => {
197+
// The response is a convenience, not an out parameter — a compose function
198+
// is free to build and return its own.
199+
const compose: ComposeFunction = (req) => {
200+
const own = to(req);
201+
own.desired = { composite: undefined, resources: {} };
202+
own.desired.resources['mine'] = Resource.fromJSON({ resource: { kind: 'Mine' } });
203+
return own;
204+
};
205+
206+
const rsp = await fromCompose(compose).RunFunction(request());
207+
208+
expect(Object.keys(rsp.desired?.resources ?? {})).toEqual(['mine']);
209+
});
210+
211+
it('should hand back a response whose desired state aliases the request', async () => {
212+
// Documented behaviour inherited from to(): when the request already
213+
// carries desired state, rsp.desired is that same object rather than a
214+
// copy, so writes through rsp are visible on req. Pinned here so that
215+
// changing it is a deliberate act rather than an accident.
216+
const req = RunFunctionRequest.fromJSON({
217+
desired: { resources: { existing: { resource: { kind: 'Existing' } } } },
218+
});
219+
220+
const compose: ComposeFunction = (_req, rsp) => {
221+
rsp.desired.resources['added'] = Resource.fromJSON({ resource: { kind: 'Added' } });
222+
return rsp;
223+
};
224+
225+
await fromCompose(compose).RunFunction(req);
226+
227+
expect(Object.keys(req.desired?.resources ?? {}).sort()).toEqual(['added', 'existing']);
228+
});
229+
230+
it('should let errors propagate so FunctionRunner can report them', async () => {
231+
const compose: ComposeFunction = () => {
232+
throw new Error('boom');
233+
};
234+
235+
await expect(fromCompose(compose).RunFunction(request())).rejects.toThrow('boom');
236+
});
237+
});

0 commit comments

Comments
 (0)