Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/test-examples.yml
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;
Comment on lines +34 to +41
Copy link

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-platform rimraf package or implementing OS-specific cleanup commands. A simple approach would be:

- name: Clean schemas
  working-directory: examples/minimal-sdk
  run: |
    if [ "$RUNNER_OS" == "Windows" ]; then
      Remove-Item -Recurse -Force src/**/schemas -ErrorAction Ignore
      Remove-Item -Recurse -Force src/**/*.type.ts -ErrorAction Ignore
      Remove-Item -Recurse -Force sdk/**/*.client.ts -ErrorAction Ignore
      Remove-Item -Force foo.openapi.json -ErrorAction Ignore
    else
      rm -rf {src,sdk}/**/schemas
      rm -rf {src,sdk}/**/*.type.ts
      rm -rf sdk/**/*.client.ts
      rm -rf foo.openapi.json
    fi

Spotted by Graphite Reviewer

Is this helpful? React 👍 or 👎 to let us know.


# 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
94 changes: 94 additions & 0 deletions examples/minimal-sdk/foo.openapi.json
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"
}
}
}
}
}
}
13 changes: 13 additions & 0 deletions examples/minimal-sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions examples/minimal-sdk/package.json
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"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script order creates a potential race condition. The generate:openapi command should run first since it generates the OpenAPI spec that the other commands depend on. Recommend reordering to:

npm run generate:openapi && npm run generate:types && npm run generate:sdk

This ensures the OpenAPI spec exists before type and SDK generation begins.

Spotted by Graphite Reviewer

Is this helpful? React 👍 or 👎 to let us know.

},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {}
}
3 changes: 3 additions & 0 deletions examples/minimal-sdk/sdk/src/foo.schema.ts
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({})
131 changes: 131 additions & 0 deletions examples/minimal-sdk/sdk/src/foo/rest.client.ts
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) {
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}`
71 changes: 71 additions & 0 deletions examples/minimal-sdk/sdk/src/foo/rest.type.ts
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
Loading
Loading