Skip to content

Commit

Permalink
add program api; add guru checks; add improve tests
Browse files Browse the repository at this point in the history
  • Loading branch information
vladkens committed Dec 19, 2023
1 parent d315225 commit 11423ad
Show file tree
Hide file tree
Showing 23 changed files with 1,319 additions and 913 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
node-version: [18, 20, 21]
name: node-${{ matrix.node-version }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.DS_Store
node_modules/
scripts/specs/
scripts/clients/
coverage/
dist/
80 changes: 48 additions & 32 deletions examples/petstore-v2.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,79 @@
// Auto-generated by https://github.com/vladkens/apigen-ts
// Source: https://petstore.swagger.io/v2/swagger.json

namespace apigen {
export type Config = {
baseUrl: string
headers: Record<string, string>
}
export type Req = Omit<RequestInit, "body"> & {
search?: Record<string, unknown>
body?: unknown
}
interface ApigenConfig {
baseUrl: string
headers: Record<string, string>
}

interface ApigenRequest extends Omit<RequestInit, "body"> {
search?: Record<string, unknown>
body?: unknown
}

export class ApiClient {
ISO_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?(?:[-+]\d{2}:?\d{2}|Z)?$/
Config: ApigenConfig

Config: apigen.Config

constructor(config?: Partial<apigen.Config>) {
this.Config = {
baseUrl: "/",
headers: {},

...config,
}
constructor(config?: Partial<ApigenConfig>) {
this.Config = { baseUrl: "/", headers: {}, ...config }
}

PopulateDates<T>(d: T): T {
if (d === null || d === undefined || typeof d !== "object") return d

const t = d as unknown as Record<string, unknown>
for (const [k, v] of Object.entries(t)) {
if (typeof v === "string" && this.ISO_FORMAT.test(v)) t[k] = new Date(v)
else if (typeof v === "object") this.PopulateDates(v)
}

return d
}

async Fetch<T>(method: string, path: string, config: apigen.Req = {}): Promise<T> {
const fallback = globalThis.location?.origin ?? undefined
const url = new URL(`${this.Config.baseUrl}/${path}`.replace(/\/+/g, "/"), fallback)
for (const [k, v] of Object.entries(config?.search ?? {})) {
async ParseError(rep: Response) {
try {
// try to parse domain error from response body
return await rep.json()
} catch (e) {
// otherwise return response as is
throw rep
}
}

async Fetch<T>(method: string, path: string, opts: ApigenRequest = {}): Promise<T> {
let base = this.Config.baseUrl
if (globalThis.location && (base === "" || base.startsWith("/"))) {
base = `${globalThis.location.origin}${base.endsWith("/") ? base : `/${base}`}`
}

const url = new URL(path, base)
for (const [k, v] of Object.entries(opts?.search ?? {})) {
url.searchParams.append(k, Array.isArray(v) ? v.join(",") : (v as string))
}
const headers = new Headers({ ...this.Config.headers, ...config.headers })

const headers = new Headers({ ...this.Config.headers, ...opts.headers })
const ct = headers.get("content-type") ?? "application/json"
let body: FormData | string | undefined = undefined
if (ct === "multipart/form-data") {
headers.delete("content-type")
body = new FormData()
for (const [k, v] of Object.entries(config.body as Record<string, string>)) {

let body: FormData | URLSearchParams | string | undefined = undefined

if (ct === "multipart/form-data" || ct === "application/x-www-form-urlencoded") {
headers.delete("content-type") // https://stackoverflow.com/a/61053359/3664464
body = ct === "multipart/form-data" ? new FormData() : new URLSearchParams()
for (const [k, v] of Object.entries(opts.body as Record<string, string>)) {
body.append(k, v)
}
}
if (ct === "application/json" && typeof config.body !== "string") {

if (ct === "application/json" && typeof opts.body !== "string") {
headers.set("content-type", "application/json")
body = JSON.stringify(config.body)
body = JSON.stringify(opts.body)
}
const rep = await fetch(url.toString(), { method, ...config, headers, body })
if (!rep.ok) throw rep

const credentials = opts.credentials ?? "include"
const rep = await fetch(url.toString(), { method, ...opts, headers, body, credentials })
if (!rep.ok) throw await this.ParseError(rep)

const rs = await rep.text()
try {
return this.PopulateDates(JSON.parse(rs))
Expand Down
80 changes: 48 additions & 32 deletions examples/petstore-v3.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,79 @@
// Auto-generated by https://github.com/vladkens/apigen-ts
// Source: https://petstore3.swagger.io/api/v3/openapi.json

namespace apigen {
export type Config = {
baseUrl: string
headers: Record<string, string>
}
export type Req = Omit<RequestInit, "body"> & {
search?: Record<string, unknown>
body?: unknown
}
interface ApigenConfig {
baseUrl: string
headers: Record<string, string>
}

interface ApigenRequest extends Omit<RequestInit, "body"> {
search?: Record<string, unknown>
body?: unknown
}

export class ApiClient {
ISO_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?(?:[-+]\d{2}:?\d{2}|Z)?$/
Config: ApigenConfig

Config: apigen.Config

constructor(config?: Partial<apigen.Config>) {
this.Config = {
baseUrl: "/",
headers: {},

...config,
}
constructor(config?: Partial<ApigenConfig>) {
this.Config = { baseUrl: "/", headers: {}, ...config }
}

PopulateDates<T>(d: T): T {
if (d === null || d === undefined || typeof d !== "object") return d

const t = d as unknown as Record<string, unknown>
for (const [k, v] of Object.entries(t)) {
if (typeof v === "string" && this.ISO_FORMAT.test(v)) t[k] = new Date(v)
else if (typeof v === "object") this.PopulateDates(v)
}

return d
}

async Fetch<T>(method: string, path: string, config: apigen.Req = {}): Promise<T> {
const fallback = globalThis.location?.origin ?? undefined
const url = new URL(`${this.Config.baseUrl}/${path}`.replace(/\/+/g, "/"), fallback)
for (const [k, v] of Object.entries(config?.search ?? {})) {
async ParseError(rep: Response) {
try {
// try to parse domain error from response body
return await rep.json()
} catch (e) {
// otherwise return response as is
throw rep
}
}

async Fetch<T>(method: string, path: string, opts: ApigenRequest = {}): Promise<T> {
let base = this.Config.baseUrl
if (globalThis.location && (base === "" || base.startsWith("/"))) {
base = `${globalThis.location.origin}${base.endsWith("/") ? base : `/${base}`}`
}

const url = new URL(path, base)
for (const [k, v] of Object.entries(opts?.search ?? {})) {
url.searchParams.append(k, Array.isArray(v) ? v.join(",") : (v as string))
}
const headers = new Headers({ ...this.Config.headers, ...config.headers })

const headers = new Headers({ ...this.Config.headers, ...opts.headers })
const ct = headers.get("content-type") ?? "application/json"
let body: FormData | string | undefined = undefined
if (ct === "multipart/form-data") {
headers.delete("content-type")
body = new FormData()
for (const [k, v] of Object.entries(config.body as Record<string, string>)) {

let body: FormData | URLSearchParams | string | undefined = undefined

if (ct === "multipart/form-data" || ct === "application/x-www-form-urlencoded") {
headers.delete("content-type") // https://stackoverflow.com/a/61053359/3664464
body = ct === "multipart/form-data" ? new FormData() : new URLSearchParams()
for (const [k, v] of Object.entries(opts.body as Record<string, string>)) {
body.append(k, v)
}
}
if (ct === "application/json" && typeof config.body !== "string") {

if (ct === "application/json" && typeof opts.body !== "string") {
headers.set("content-type", "application/json")
body = JSON.stringify(config.body)
body = JSON.stringify(opts.body)
}
const rep = await fetch(url.toString(), { method, ...config, headers, body })
if (!rep.ok) throw rep

const credentials = opts.credentials ?? "include"
const rep = await fetch(url.toString(), { method, ...opts, headers, body, credentials })
if (!rep.ok) throw await this.ParseError(rep)

const rs = await rep.text()
try {
return this.PopulateDates(JSON.parse(rs))
Expand Down
52 changes: 52 additions & 0 deletions logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 17 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@
"build": "rm -rf dist && pkgroll && cp ./src/_template.ts ./dist && ls -lah dist",
"test": "uvu -r tsm test '\\.test\\.ts$'",
"test-cov": "c8 --include=src yarn test",
"test-watch": "watchexec -c -e ts 'clear && yarn test'",
"format": "prettier --write .",
"ci": "yarn test-cov && yarn build"
"ci": "tsc --noEmit && yarn test-cov && yarn build"
},
"dependencies": {
"@redocly/openapi-core": "^1.4.1",
Expand All @@ -32,7 +31,7 @@
"swagger2openapi": "^7.0.8"
},
"devDependencies": {
"@types/node": "^18.18.0",
"@types/node": "^20.10.5",
"c8": "^8.0.1",
"fetch-mock": "^9.11.0",
"pkgroll": "^2.0.1",
Expand All @@ -46,10 +45,21 @@
"prettier": "^3.0.0",
"typescript": "^5.0.0"
},
"bin": {
"apigen-ts": "./dist/cli.js"
},
"files": [
"dist"
]
],
"types": "./dist/main.d.cts",
"exports": {
"require": {
"types": "./dist/main.d.cts",
"default": "./dist/main.cjs"
},
"import": {
"types": "./dist/main.d.mts",
"default": "./dist/main.mjs"
}
},
"bin": {
"apigen-ts": "./dist/cli.js"
}
}
Loading

0 comments on commit 11423ad

Please sign in to comment.