Skip to content

Commit 9b00c08

Browse files
committed
feat: options paramater, supporting { validParams: false }
See Note 3 about in README about "additionalProperties in params" and the example for discourse.updateUser({ title: "X" }).
1 parent bf6335a commit 9b00c08

File tree

11 files changed

+979
-209
lines changed

11 files changed

+979
-209
lines changed

README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,17 +168,31 @@ following APIs were supported: (link is to official docs, in same order).
168168
spec) but not provided, we'll try the call anyway and let the endpoint
169169
decide.
170170

171+
1. By default, **additionalProperties in params** are disallowed. This is to
172+
help prevent typos. However, often there are undocumented parameters that
173+
still work, in which case, you can disable param validation. e.g.
174+
175+
```ts
176+
await discourse.updateUser({
177+
username: "user to update",
178+
// @ts-expect-error: not in spec <-- disable type check
179+
title: "a new title",
180+
}, {
181+
validateParams: false, // <-- disable runtime validation
182+
});
183+
```
184+
171185
1. Currently, **the response is not validated**, because unfortunately, the
172186
returned data often does not validate against the OpenAPI schema
173187
(`additionalProperties`, missing `required` props, wrong types).
174188

175-
I'm still deciding what to do with about this, feedback (in an issue) would
176-
be greatly appreciated. In theory I'd like to make this a configurable
177-
option, but if we don't validate, we really should be returning the data as
178-
an `unknown` type so the user performs their own validation, which is a pain,
179-
and you'll lose typescript completion. However, on the flip side, what we do
180-
now is return a type that is wrong, and TypeScript won't warn about missing
181-
(but now required) checks.
189+
I'm still deciding what to do with about this, feedback (in an issue) would be
190+
greatly appreciated. In theory I'd like to make this a configurable option, but
191+
if we don't validate, we really should be returning the data as an `unknown`
192+
type so the user performs their own validation, which is a pain, and you'll lose
193+
typescript completion. However, on the flip side, what we do now is return a
194+
type that is wrong, and TypeScript won't warn about missing (but now required)
195+
checks.
182196

183197
1. Installed Discourse extensions / plugins may affect the result! It can add
184198
additional properties, etc. Likewise, running older versions of Discourse may
@@ -194,3 +208,6 @@ following APIs were supported: (link is to official docs, in same order).
194208
## Development
195209

196210
See [CONTRIBUTING.md](./CONTRIBUTING.md).
211+
212+
```
213+
```

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/generate.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ const methods = [
4040
] as Method[];
4141

4242
(async () => {
43-
let out = 'import type { operations } from "./schema.d.ts";' + "\n\n";
43+
let out = 'import type { operations } from "./schema.d.ts";' + "\n" +
44+
'import type { DiscourseExecOptions } from "./types.ts";' + "\n\n";
45+
4446
out +=
4547
"type Prettify<T> = {\n [K in keyof T]: T[K];\n// deno-lint-ignore ban-types\n} & {}\n\n";
4648
out += "export default class DiscourseAPIGenerated {\n";
4749
out +=
48-
" _exec<T>(_operationName: string, _params?: unknown) { throw new Error('Not implemented'); }\n\n";
50+
" _exec<T>(_operationName: string, _params?: unknown, options?: unknown) { throw new Error('Not implemented'); }\n\n";
4951

5052
for (const [path, pathData] of objectEntries(spec.paths)) {
5153
for (const method of methods) {
@@ -156,6 +158,10 @@ const methods = [
156158
}
157159
}
158160

161+
const hasParams = types.length || methodData.requestBody;
162+
methodString += (hasParams ? "," : "") +
163+
"options?: Prettify<DiscourseExecOptions>";
164+
159165
methodString += ")";
160166

161167
let returnType = null;
@@ -204,8 +210,8 @@ const methods = [
204210
"']>('" +
205211
operationId +
206212
"'" +
207-
(types.length || methodData.requestBody ? ", params" : "") +
208-
")" +
213+
(hasParams ? ", params" : "") +
214+
", options)" +
209215
(returnType ? " as unknown as " + returnType : "") +
210216
";\n",
211217
);

src/generated.ts

Lines changed: 180 additions & 179 deletions
Large diffs are not rendered by default.

src/index.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,18 @@ import _ajvErrors from "ajv-errors";
2222
import _ajvFormats from "ajv-formats";
2323
import type { OpenAPIV3_1 } from "openapi-types";
2424

25+
import type { DiscourseExecOptions } from "./types.ts";
26+
export * from "./types.ts";
27+
2528
import spec from "./openapi.json" with { type: "json" };
2629
import DiscourseAPIGenerated from "./generated.ts";
2730

31+
const execOptionDefaults: DiscourseExecOptions = {
32+
fetchOptions: {},
33+
validateParams: true,
34+
// validateResponse: true,
35+
};
36+
2837
// Previously we imported this from ./generated.ts, but, exporting it
2938
// from there breaks the unwrapping.
3039
type Prettify<T> =
@@ -181,9 +190,21 @@ export class HTTPError extends Error {
181190
url: string,
182191
requestInit: RequestInit,
183192
) {
193+
const ri = {
194+
...requestInit,
195+
headers: requestInit.headers instanceof Headers
196+
? Object.fromEntries(requestInit.headers.entries())
197+
: requestInit.headers as
198+
| Record<string, string | string[] | undefined>
199+
| undefined,
200+
};
201+
if (ri.headers?.["api-key"]) {
202+
ri.headers["api-key"] = ri.headers["api-key"].slice(0, 7) + "...";
203+
}
204+
184205
super(
185206
`An error occured calling "${operationName}" at ${url}\n` +
186-
`RequestInit: ${JSON.stringify(requestInit)}\n` +
207+
`RequestInit: ${JSON.stringify(ri)}\n` +
187208
`Returned HTTP ${status}: ${body}`,
188209
);
189210
this.status = status;
@@ -251,10 +272,13 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
251272
override async _exec<T>(
252273
operationName: string,
253274
params = {} as Record<string, string>,
275+
options: DiscourseExecOptions = {},
254276
): Promise<unknown> {
255277
const operation = byOperationId[operationName];
256278
if (!operation) throw new Error("Unknown operation: " + operationName);
257279

280+
const opts = { ...execOptionDefaults, ...options };
281+
258282
// console.log(operationName, params);
259283

260284
const header: { [key: string]: string } = {};
@@ -352,22 +376,40 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
352376

353377
const additionalProperties = Object.keys(params);
354378
if (additionalProperties.length) {
355-
throw new Error(
356-
"Unknown parameter(s) for " +
357-
operationName +
358-
": " +
359-
additionalProperties.join(", "),
360-
);
379+
if (opts.validateParams) {
380+
throw new Error(
381+
"Unknown parameter(s) for " +
382+
operationName +
383+
": " +
384+
additionalProperties.join(", "),
385+
);
386+
} else {
387+
if (formData) {
388+
for (const key of additionalProperties) {
389+
formData.append(key, params[key] as string | Blob);
390+
}
391+
} else if (contentType === "application/json") {
392+
for (const key of additionalProperties) {
393+
body[key] = params[key];
394+
}
395+
} else {
396+
throw new Error(
397+
"additionalProperties but not sure where to put them",
398+
);
399+
}
400+
}
361401
}
362402

363-
const validate = getValidator(operationSchema(operation.data));
364-
const valid = validate({ header, path, query, body });
365-
if (!valid) {
366-
const errors = validate.errors;
367-
// console.log("errors", errors);
368-
const message = ajv.errorsText(errors);
369-
const error = new ParamaterValidationError(message, errors);
370-
throw error;
403+
if (opts.validateParams) {
404+
const validate = getValidator(operationSchema(operation.data));
405+
const valid = validate({ header, path, query, body });
406+
if (!valid) {
407+
const errors = validate.errors;
408+
// console.log("errors", errors);
409+
const message = ajv.errorsText(errors);
410+
const error = new ParamaterValidationError(message, errors);
411+
throw error;
412+
}
371413
}
372414

373415
// Do this after validation (of user headers)
@@ -396,6 +438,7 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
396438
method: operation.method,
397439
headers: new Headers(header),
398440
redirect: "manual",
441+
...opts.fetchOptions,
399442
};
400443

401444
if (operation.data.requestBody && "content" in operation.data.requestBody) {
@@ -481,30 +524,33 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
481524
file?: File | Blob;
482525
}
483526
>,
527+
options?: Prettify<DiscourseExecOptions>,
484528
): Prettify<ReturnType<DiscourseAPIGenerated["createUpload"]>> {
485529
const file = params.file;
486530
if (file && !(file instanceof File || file instanceof Blob)) {
487531
throw new Error("file must be a File or Blob, not " + typeof file);
488532
}
489533

490534
// @ts-expect-error: intentional break of types
491-
return super.createUpload(params);
535+
return super.createUpload(params, options);
492536
}
493537

494538
override updateTopic(
495539
params: Parameters<DiscourseAPIGenerated["updateTopic"]>[0],
540+
options?: Prettify<DiscourseExecOptions>,
496541
): ReturnType<DiscourseAPIGenerated["updateTopic"]> {
497542
console.warn(
498543
"At time of writing, updateTopic() is broken on the discourse side, see https://meta.discourse.org/t/stumped-on-api-update-of-topic/145330.",
499544
);
500-
return super.updateTopic(params);
545+
return super.updateTopic(params, options);
501546
}
502547

503548
override async getTopicByExternalId(
504549
params: Parameters<DiscourseAPIGenerated["getTopicByExternalId"]>[0],
550+
options?: Prettify<DiscourseExecOptions>,
505551
): ReturnType<DiscourseAPIGenerated["getTopic"]> {
506552
try {
507-
await super.getTopicByExternalId(params);
553+
await super.getTopicByExternalId(params, options);
508554
} catch (error) {
509555
if (error instanceof HTTPError && error.status === 301) {
510556
const location = error.response.headers.get("location");
@@ -525,6 +571,7 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
525571

526572
override getSpecificPostsFromTopic(
527573
params: Parameters<DiscourseAPIGenerated["getSpecificPostsFromTopic"]>[0],
574+
options?: Prettify<DiscourseExecOptions>,
528575
): ReturnType<DiscourseAPIGenerated["getSpecificPostsFromTopic"]> {
529576
const operation = byOperationId.getSpecificPostsFromTopic.data;
530577

@@ -555,7 +602,7 @@ export default class DiscourseAPI extends DiscourseAPIGenerated {
555602
delete operation.requestBody;
556603
}
557604

558-
return super.getSpecificPostsFromTopic(params);
605+
return super.getSpecificPostsFromTopic(params, options);
559606
}
560607

561608
/*

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface DiscourseExecOptions {
2+
fetchOptions?: RequestInit;
3+
validateParams?: boolean;
4+
// validateResponse?: boolean;
5+
}

tests/e2e/users.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
describe,
44
discourse,
55
expect,
6+
setNextCacheId,
67
skipCacheOnce,
78
test,
89
useCache,
@@ -91,6 +92,36 @@ describe("users", () => {
9192
await discourse.deleteUser({ id: user.user.id });
9293
});
9394

95+
test("updateUser with custom fields", async () => {
96+
setNextCacheId("updateUserCustom-1-createUser");
97+
const createUserResult = await discourse.createUser({
98+
name: "Test user to update",
99+
email: "test-update@example.com",
100+
password: "teSt1ngIsFuN",
101+
username: "test-update-custom",
102+
active: true,
103+
});
104+
expect(createUserResult).toMatchObject({ success: true });
105+
106+
setNextCacheId("updateUserCustom-2-updateUser");
107+
const result = await discourse.updateUser({
108+
username: "test-update-custom",
109+
// @ts-expect-error: not in spec
110+
title: "a new title",
111+
}, { validateParams: false });
112+
expect(result).toMatchObject({
113+
success: "OK",
114+
user: {
115+
title: "a new title",
116+
},
117+
});
118+
119+
setNextCacheId("updateUserCustom-3-getUser");
120+
const user = await discourse.getUser({ username: "test-update-custom" });
121+
setNextCacheId("updateUserCustom-4-deleteUser");
122+
await discourse.deleteUser({ id: user.user.id });
123+
});
124+
94125
test("getUserExternalId", async () => {
95126
/*
96127
const updateResult = await discourse.updateUser({
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"request": {
3+
"url": "http://localhost/users.json",
4+
"method": "POST",
5+
"bodyJson": {
6+
"name": "Test user to update",
7+
"email": "test-update@example.com",
8+
"password": "teSt1ngIsFuN",
9+
"username": "test-update-custom",
10+
"active": true
11+
},
12+
"headers": {
13+
"api-key": "eb2a2de8f93102c896aff8a55eec4672bdbd2be123f02c4900ad6d0f6483703b",
14+
"api-username": "system",
15+
"content-type": "application/json"
16+
}
17+
},
18+
"response": {
19+
"ok": true,
20+
"status": 200,
21+
"statusText": "OK",
22+
"headers": {
23+
"cache-control": "no-cache, no-store",
24+
"connection": "keep-alive",
25+
"content-type": "application/json; charset=utf-8",
26+
"date": "Sat, 11 Oct 2025 11:48:34 GMT",
27+
"referrer-policy": "strict-origin-when-cross-origin",
28+
"server": "nginx",
29+
"set-cookie": [
30+
"_t=CI%2B0OHDxymX15daiQu8cZ%2BGJKN7vT%2BQ3WNME7AAp3dHsDhfBb0TFwRptkSVUGqSC8UG7J5XqdxTxfqNjboY1vOcZnZJRA2Uh5cr17PV4cEnyMeWdtYGSi%2BN%2BhXD9m%2BGLUYI%2FC2Ioy6pp%2FnbjvR%2F%2FCTl75dF3U1euQoTPj2AhHMJ0Q592OTaYkFwLIroCkxs6FhOQxihZIlOKONo9r9uewr%2FCD2FTG0%2Bkfpw%2BD9Q2QTpbRsYWHIhS0pHaz0qdFfdJV%2Fdvb9dJTMCX6gIcHfz6L1xS0jTq1oYupL9C5jfayPFrRaNONf1f9fRLVbtml4vwKGT0C2z9jNA%3D--mhqNB5x%2BBFzuv4nQ--VzaV2LzzllUSzCBI%2BEKTow%3D%3D; path=/; expires=Wed, 10 Dec 2025 11:48:34 GMT; HttpOnly; SameSite=Lax",
31+
"_forum_session=eqDe2O2RkPpgl9ge2dqa12YoMr2PzOOylBMIwRGlnHWB%2FLLVVkDMeGmTBRHnz9EJPhS535Qe2iFdv9s2cdK716LENPoUfhtvitd4VwJM5bpQuyngagOqOlhk4Syt6ocFzJ4ZMdf832jehvXQ2eiB5aiYIUh5%2FZF99HXkQ6fXaRfaWsQYdijw6GlCtHBJy38midzpJiXZdKLShDn9YL8%2BqDXcnYaiRV%2BzRbposhr5chWxMnSo%2BidLFwwgkMbjasrMmK2MCHCB6kDUFbXjyKaoBAIpGNmnOGSmVeR9DYTYYJbgF5DQ8TZ%2BaRjWMsYxvRt6rryk6qXYz0AQlSUueKDWOlDuA53QoY84oP%2FPnDcfNijW0lWzS5h3fjXZANah%2F2fJ8G%2FpZUPIY9BHSs57l6jA4l5yp99I5uX9DADzvR89D7H9Lw%3D%3D--nt3X283MxcULdFue--mHmFyiPm0kGfDl7Z6dKokA%3D%3D; path=/; HttpOnly; SameSite=Lax"
32+
],
33+
"transfer-encoding": "chunked",
34+
"vary": "Accept-Encoding",
35+
"x-content-type-options": "nosniff",
36+
"x-discourse-route": "users/create",
37+
"x-discourse-username": "system",
38+
"x-frame-options": "SAMEORIGIN",
39+
"x-permitted-cross-domain-policies": "none",
40+
"x-request-id": "94206191-aac1-4781-bf0d-e97700770616",
41+
"x-runtime": "0.421281",
42+
"x-xss-protection": "0"
43+
},
44+
"bodyJson": {
45+
"success": true,
46+
"active": true,
47+
"message": "Your account is activated and ready to use."
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)