-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add minimal example with SDK generation #62
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
name: Validate Example generation | ||
|
||
on: push | ||
|
||
jobs: | ||
generate_sdk: | ||
strategy: | ||
matrix: | ||
os: [windows-latest] | ||
shell: [pwsh] | ||
include: | ||
- os: ubuntu-latest | ||
shell: bash | ||
runs-on: ${{ matrix.os }} | ||
defaults: | ||
run: | ||
shell: ${{ matrix.shell }} | ||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v3 | ||
|
||
- name: Setup Node.js | ||
uses: actions/setup-node@v3 | ||
with: | ||
node-version: "22" | ||
|
||
- name: Install dependencies | ||
run: npm ci | ||
|
||
- name: Install dependencies in example | ||
working-directory: examples/minimal-sdk | ||
run: npm ci | ||
|
||
- name: Clean schemas | ||
working-directory: examples/minimal-sdk | ||
shell: bash | ||
run: | | ||
rm -rf {src,sdk}/**/schemas; | ||
rm -rf {src,sdk}/**/*.type.ts; | ||
rm -rf sdk/**/*.client.ts; | ||
rm -rf foo.openapi.json; | ||
|
||
# Step 6: Generate types & sdk | ||
- name: Generate types & sdk | ||
working-directory: examples/minimal-sdk | ||
run: npm run generate | ||
|
||
# Step 7: Expect no changes in git | ||
- name: Expect no changes | ||
shell: bash | ||
run: | | ||
if git diff --exit-code --ignore-cr-at-eol; then | ||
echo "No changes detected" | ||
else | ||
echo "ERROR: Changes detected. The command executed by the pipeline resulted in changes that should have been made locally before creating a commit." | ||
exit 1 | ||
fi |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
{ | ||
"openapi": "3.1.0", | ||
"info": { | ||
"title": "example-foo - foo", | ||
"version": "1.0.0" | ||
}, | ||
"servers": [ | ||
{ | ||
"url": "https://localhost/" | ||
} | ||
], | ||
"components": { | ||
"securitySchemes": {}, | ||
"schemas": { | ||
"ErrorResponse": { | ||
"$schema": "http://json-schema.org/draft-07/schema#", | ||
"title": "HttpError", | ||
"type": "object", | ||
"description": "The default error error response for both 400 & 500 type errors", | ||
"properties": { | ||
"statusCode": { | ||
"type": "integer", | ||
"description": "The status code of the response." | ||
}, | ||
"message": { | ||
"type": "string", | ||
"description": "A detailed message of the error." | ||
} | ||
}, | ||
"required": ["message", "statusCode"], | ||
"additionalProperties": true | ||
}, | ||
"FooData": { | ||
"type": "object", | ||
"properties": { | ||
"message": { | ||
"type": "string" | ||
} | ||
}, | ||
"required": ["message"], | ||
"additionalProperties": true | ||
}, | ||
"FooResponse": { | ||
"title": "FooResponse", | ||
"type": "object", | ||
"properties": { | ||
"data": { | ||
"$ref": "#/components/schemas/FooData" | ||
} | ||
}, | ||
"required": ["data"], | ||
"additionalProperties": true | ||
} | ||
}, | ||
"requestBodies": {}, | ||
"responses": { | ||
"ErrorResponse": { | ||
"description": "The default error error response for both 400 & 500 type errors", | ||
"content": { | ||
"application/json": { | ||
"schema": { | ||
"$ref": "#/components/schemas/ErrorResponse" | ||
} | ||
} | ||
} | ||
}, | ||
"FooResponse": { | ||
"description": "", | ||
"content": { | ||
"application/json": { | ||
"schema": { | ||
"$ref": "#/components/schemas/FooResponse" | ||
} | ||
} | ||
} | ||
} | ||
} | ||
}, | ||
"paths": { | ||
"/foo": { | ||
"get": { | ||
"parameters": [], | ||
"responses": { | ||
"200": { | ||
"$ref": "#/components/responses/FooResponse" | ||
}, | ||
"default": { | ||
"$ref": "#/components/responses/ErrorResponse" | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
{ | ||
"name": "minimal", | ||
"version": "1.0.0", | ||
"type": "module", | ||
"scripts": { | ||
"generate:openapi": "node ../../bin/run.js openapi && npx biome format --write foo.openapi.json", | ||
"generate:types": "npx therefore -f src --clean", | ||
"generate:sdk": "npx therefore -f sdk --clean", | ||
"generate": "npm run generate:types && npm run generate:sdk && npm run generate:openapi" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The script order creates a potential race condition. The
This ensures the OpenAPI spec exists before type and SDK generation begins. Spotted by Graphite Reviewer |
||
}, | ||
"keywords": [], | ||
"author": "", | ||
"license": "ISC", | ||
"description": "", | ||
"dependencies": {} | ||
MickVanDuijn marked this conversation as resolved.
Show resolved
Hide resolved
MickVanDuijn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import { $sdk } from '../../../../src/lib/sdk.js' | ||
|
||
export default await $sdk({}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
/** | ||
* Generated by @skyleague/therefore | ||
* Do not manually touch this | ||
*/ | ||
/* eslint-disable */ | ||
|
||
import type { IncomingHttpHeaders } from 'node:http' | ||
|
||
import type { DefinedError } from 'ajv' | ||
import { got } from 'got' | ||
import type { CancelableRequest, Got, Options, OptionsInit, Response } from 'got' | ||
|
||
import { ErrorResponse, FooResponse } from './rest.type.js' | ||
|
||
/** | ||
* example-foo - foo | ||
*/ | ||
export class FooClient { | ||
public client: Got | ||
|
||
public constructor({ | ||
prefixUrl = 'https://localhost/', | ||
options, | ||
client = got, | ||
}: { | ||
prefixUrl?: string | 'https://localhost/' | ||
options?: Options | OptionsInit | ||
client?: Got | ||
} = {}) { | ||
this.client = client.extend( | ||
...[{ prefixUrl, throwHttpErrors: false }, options].filter((o): o is Options => o !== undefined), | ||
) | ||
} | ||
|
||
/** | ||
* GET /foo | ||
*/ | ||
public getFoo(): Promise< | ||
| SuccessResponse<'201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226', ErrorResponse> | ||
| SuccessResponse<'200', FooResponse> | ||
| FailureResponse<StatusCode<2>, string, 'response:body', IncomingHttpHeaders> | ||
| FailureResponse<StatusCode<1 | 3 | 4 | 5>, string, 'response:statuscode', IncomingHttpHeaders> | ||
> { | ||
return this.awaitResponse( | ||
this.client.get('foo', { | ||
responseType: 'json', | ||
}), | ||
{ | ||
200: FooResponse, | ||
default: ErrorResponse, | ||
}, | ||
) as ReturnType<this['getFoo']> | ||
} | ||
|
||
public async awaitResponse< | ||
I, | ||
S extends Record<PropertyKey, { parse: (o: I) => { left: DefinedError[] } | { right: unknown } }>, | ||
>(response: CancelableRequest<NoInfer<Response<I>>>, schemas: S) { | ||
MickVanDuijn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const result = await response | ||
const status = | ||
result.statusCode < 200 | ||
? 'informational' | ||
: result.statusCode < 300 | ||
? 'success' | ||
: result.statusCode < 400 | ||
? 'redirection' | ||
: result.statusCode < 500 | ||
? 'client-error' | ||
: 'server-error' | ||
const validator = schemas[result.statusCode] ?? schemas.default | ||
const body = validator?.parse?.(result.body) | ||
if (result.statusCode < 200 || result.statusCode >= 300) { | ||
return { | ||
success: false as const, | ||
statusCode: result.statusCode.toString(), | ||
status, | ||
headers: result.headers, | ||
left: body !== undefined && 'right' in body ? body.right : result.body, | ||
validationErrors: body !== undefined && 'left' in body ? body.left : undefined, | ||
where: 'response:statuscode', | ||
} | ||
} | ||
if (body === undefined || 'left' in body) { | ||
return { | ||
success: body === undefined, | ||
statusCode: result.statusCode.toString(), | ||
status, | ||
headers: result.headers, | ||
left: result.body, | ||
validationErrors: body?.left, | ||
where: 'response:body', | ||
} | ||
} | ||
return { | ||
success: true as const, | ||
statusCode: result.statusCode.toString(), | ||
status, | ||
headers: result.headers, | ||
right: result.body, | ||
} | ||
} | ||
} | ||
|
||
export type Status<Major> = Major extends string | ||
? Major extends `1${number}` | ||
? 'informational' | ||
: Major extends `2${number}` | ||
? 'success' | ||
: Major extends `3${number}` | ||
? 'redirection' | ||
: Major extends `4${number}` | ||
? 'client-error' | ||
: 'server-error' | ||
: undefined | ||
export interface SuccessResponse<StatusCode extends string, T> { | ||
success: true | ||
statusCode: StatusCode | ||
status: Status<StatusCode> | ||
headers: IncomingHttpHeaders | ||
right: T | ||
} | ||
export interface FailureResponse<StatusCode = string, T = unknown, Where = never, Headers = IncomingHttpHeaders> { | ||
success: false | ||
statusCode: StatusCode | ||
status: Status<StatusCode> | ||
headers: Headers | ||
validationErrors: DefinedError[] | undefined | ||
left: T | ||
where: Where | ||
} | ||
export type StatusCode<Major extends number = 1 | 2 | 3 | 4 | 5> = `${Major}${number}` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
/** | ||
* Generated by @skyleague/therefore | ||
* Do not manually touch this | ||
*/ | ||
/* eslint-disable */ | ||
|
||
import type { DefinedError, ValidateFunction } from 'ajv' | ||
|
||
import { validate as ErrorResponseValidator } from './schemas/error-response.schema.js' | ||
import { validate as FooResponseValidator } from './schemas/foo-response.schema.js' | ||
|
||
/** | ||
* HttpError | ||
* | ||
* The default error error response for both 400 & 500 type errors | ||
*/ | ||
export interface ErrorResponse { | ||
/** | ||
* A detailed message of the error. | ||
*/ | ||
message: string | ||
/** | ||
* The status code of the response. | ||
*/ | ||
statusCode: number | ||
} | ||
|
||
export const ErrorResponse = { | ||
validate: ErrorResponseValidator as ValidateFunction<ErrorResponse>, | ||
get schema() { | ||
return ErrorResponse.validate.schema | ||
}, | ||
get errors() { | ||
return ErrorResponse.validate.errors ?? undefined | ||
}, | ||
is: (o: unknown): o is ErrorResponse => ErrorResponse.validate(o) === true, | ||
parse: (o: unknown): { right: ErrorResponse } | { left: DefinedError[] } => { | ||
if (ErrorResponse.is(o)) { | ||
return { right: o } | ||
} | ||
return { left: (ErrorResponse.errors ?? []) as DefinedError[] } | ||
}, | ||
} as const | ||
|
||
export interface FooData { | ||
message: string | ||
} | ||
|
||
/** | ||
* FooResponse | ||
*/ | ||
export interface FooResponse { | ||
data: FooData | ||
} | ||
|
||
export const FooResponse = { | ||
validate: FooResponseValidator as ValidateFunction<FooResponse>, | ||
get schema() { | ||
return FooResponse.validate.schema | ||
}, | ||
get errors() { | ||
return FooResponse.validate.errors ?? undefined | ||
}, | ||
is: (o: unknown): o is FooResponse => FooResponse.validate(o) === true, | ||
parse: (o: unknown): { right: FooResponse } | { left: DefinedError[] } => { | ||
if (FooResponse.is(o)) { | ||
return { right: o } | ||
} | ||
return { left: (FooResponse.errors ?? []) as DefinedError[] } | ||
}, | ||
} as const |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While this workflow runs on both Windows and Ubuntu, the
rm -rf
commands will fail on Windows even when using PowerShell. Consider using the cross-platformrimraf
package or implementing OS-specific cleanup commands. A simple approach would be:Spotted by Graphite Reviewer
Is this helpful? React 👍 or 👎 to let us know.