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

Add OpenAPI Script for generating API fetch methods #13

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

cocowmn
Copy link
Collaborator

@cocowmn cocowmn commented Jan 13, 2024

Overview

Add root scripts directory which contains openapi-codegen CLI project.

The goal of this project is to use the backend's auto-generated OpenAPI3 schema to generate frontend code for interfacing with the backend, specifically to be used with SvelteKit.

Run the command with

# The current working directory is scripts/openapi-codegen
$ npx bun src/app.ts

This will generate three files in a new dist/api folder: models.ts which exports the Entity type definitions; endpoints.ts which exports the http fetch methods to interact with the API; and index.ts which allows you to import from the api folder instead of from the individual files

import { type Recipient, getAllRecipients } from '$api'

instead of

import type { Recipient } from '$api/models'
import { getAllRecipients } from '$api/endpoints'

Codegen Script Details

From the project directory scripts/openapi-codegen, run the following command to see the --help screen:

npx bun src/app.ts --help
# or, if bun is installed on your machine
bun src/app.ts --help
Usage: bellbooks-openapi-codegen [options]

CLI to generate client API methods to interface with BellBooks API

Options:
  -V, --version             output the version number
  -l, --local               (default) Set URL to the local instance of Bellbooks API docs endpoint
  -r, --remote              Set URL to the GCP-hosted instance of Bellbooks API docs endpoint
  -u, --url <url>           URL pointing to a live instance of the Bellbooks API documentation (default:
                            "http://localhost:8080/api/docs")
  -o, --output <path>       Designate the output directory for the generated code (default: "dist/api")
  -ll, --log-level <level>  Set logging level (choices: "none", "error", "info", "verbose", default:
                            "info")
  -v, --verbose             Set logging level to verbose
  -d, --dry-run             [Test] Log to standard output without saving to disk (default: false)
  -h, --help                display help for command

Four additional npm scripts have been added to the openapi-codegen package.json file to save keystrokes on common tasks:

  • $ npm run local
    • Downloads the openapi docs from a running local instance of bellbooks and saves to the root project directory src/lib/api
  • $ npm run local:test
    • Runs agains the same API as local, but does a dry-run, logging to console instead of saving to disk
  • $ npm run remote
    • Downloads the openapi docs from the GCP-hosted instance of bellbooks and saves to the root project directory src/lib/api
  • $ npm run remote:test
    • Runs agains the same API as remote, but does a dry-run, logging to console instead of saving to disk

The above scripts all require that the current working directory be scripts/openapi-codegen. Two additional alias scripts have been added in the root package.json that can be run from the project's root directory:

  • $npm run openapi-codegen:local -> runs the local script
  • $npm run openapi-codegen:remote -> runs the remote script

The CLI commands can also be run individually using custom flags, for example:

# current working directory is scripts/openapi-codegen
$ npx bun src/app.ts --local --output src/my/custom/output.json

See the associated --help command above for more details on all available options/flags.

Sample Generated Code

models.ts
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }
export type ForeignKey<
Entity extends
  | Book
  | Facility
  | Note
  | PackageContent
  | Recipient
  | Shipment
  | SpecialRequest
  | Zine,
> = Required<Pick<Entity, 'id'>> & Partial<Entity>

export type Book = WithRequired<
{
  type: 'Book'
} & Omit<PackageContent, 'type'> & {
    authors?: string
    isbn10?: string
    isbn13?: string
  },
'title'
>

export type Facility = {
/** Format: int64 */
id?: number
name: string
additionalInfo?: string
street: string
city: string
/** @enum {string} */
state: 'NC' | 'AL' | 'TN' | 'WV' | 'KY' | 'MD' | 'VA' | 'DE'
zip: string
}

export type Note = {
/** Format: int64 */
id?: number
content?: string
/** Format: date */
date?: string
}

export type PackageContent = {
/** Format: int64 */
id?: number
title: string
type: string
}

export type Recipient = {
/** Format: int64 */
id?: number
firstName: string
middleName?: string
lastName: string
assignedId?: string
facility?: ForeignKey<Facility>
shipments?: ForeignKey<Shipment>[]
specialRequests?: ForeignKey<SpecialRequest>[]
}

export type Shipment = {
/** Format: int64 */
id?: number
/** Format: date */
date?: string
facility?: ForeignKey<Facility>
recipient?: ForeignKey<Recipient>
notes?: ForeignKey<Note>[]
content?: (ForeignKey<Book> | ForeignKey<Zine>)[]
}

export type SpecialRequest = {
/** Format: int64 */
id?: number
volunteerName?: string
request?: string
/** Format: date */
specialRequestDate?: string
/** Format: date */
letterMailedDate?: string
/** @enum {string} */
category?:
  | 'VOCATIONAL'
  | 'EDUCATIONAL'
  | 'CAREER_GROWTH'
  | 'FOREIGN_LANGUAGE'
  | 'LEGAL'
  | 'SPIRITUAL_RELIGIOUS'
  | 'OTHER'
/** @enum {string} */
status?: 'OPEN' | 'COMPLETED' | 'CANCELLED'
recipient?: ForeignKey<Recipient>
}

export type Zine = WithRequired<
{
  type: 'Zine'
} & Omit<PackageContent, 'type'> & {
    code?: string
  },
'code' | 'title'
>
endpoints.ts
import type {
  Book,
  Facility,
  Note,
  PackageContent,
  Recipient,
  Shipment,
  SpecialRequest,
  Zine,
} from './models'

type Fetch = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>

export const BASE_URL = 'http://localhost:8080'

function resolveURL(
  url: URL,
  { query, path }: { query?: { [index: string]: any }; path?: { [index: string]: any } },
): string {
  let resolvedURL = url.toString()

  if (path) {
    for (const [key, value] of Object.entries(path)) {
      const variablePattern = new RegExp(`{s*${key}s*}`, 'g')
      resolvedURL = resolvedURL.replace(variablePattern, value)
    }
  }

  if (query) {
    const searchParams = new URLSearchParams(query)
    const queryString = searchParams.toString()

    if (queryString) {
      resolvedURL += resolvedURL.includes('?') ? `&${queryString}` : `?${queryString}`
    }
  }

  return resolvedURL
}

export async function updateShipment(fetch: Fetch, body: Shipment): Promise<Shipment | undefined> {
  const url = new URL('/updateShipment', BASE_URL).toString()
  const options: RequestInit = {
    method: 'put',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Shipment
  } catch (error) {
    console.error(
      `received error while fetching url("${url}") with data(${JSON.stringify(body)})`,
      error,
    )
    return undefined
  }
}

...

export async function addRecipient(fetch: Fetch, body: Recipient): Promise<Recipient | undefined> {
  const url = new URL('/addRecipient', BASE_URL).toString()
  const options: RequestInit = {
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Recipient
  } catch (error) {
    console.error(
      `received error while fetching url("${url}") with data(${JSON.stringify(body)})`,
      error,
    )
    return undefined
  }
}

...

export async function searchBooksByTitleAndAuthor(
  fetch: Fetch,
  query: { title: string; author?: string },
): Promise<Book[] | undefined> {
  const url = resolveURL(new URL('/searchBooks', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Book[]
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}

export async function queryGoogleByTitleAndAuthor(
  fetch: Fetch,
  query: { title: string; author?: string },
): Promise<Book[] | undefined> {
  const url = resolveURL(new URL('/queryGoogle', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Book[]
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}

export async function getZineByCode(
  fetch: Fetch,
  query: { code: string },
): Promise<Zine | undefined> {
  const url = resolveURL(new URL('/getZineByCode', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Zine
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}
...

export async function getShipmentCountBetweenDates(
  fetch: Fetch,
  query: { date1: string; date2: string },
): Promise<number | undefined> {
  const url = resolveURL(new URL('/getShipmentCountBetweenDates', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as number
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}
...

export async function getRecipientLocation(
  fetch: Fetch,
  query: { id: string },
): Promise<string | undefined> {
  const url = resolveURL(new URL('/getRecipientLocation', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as string
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}
...

export async function getBooksWithNoIsbn(fetch: Fetch): Promise<Book[] | undefined> {
  const url = new URL('/getNoIsbnBooks', BASE_URL).toString()
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Book[]
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}
...

export async function getFacilityByNameAndState(
  fetch: Fetch,
  query: { name: string; state: 'NC' | 'AL' | 'TN' | 'WV' | 'KY' | 'MD' | 'VA' | 'DE' },
): Promise<Facility[] | undefined> {
  const url = resolveURL(new URL('/getFacilityByName', BASE_URL), { query })
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Facility[]
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}

...

export async function getAllZines(fetch: Fetch): Promise<Zine[] | undefined> {
  const url = new URL('/getAllZines', BASE_URL).toString()
  const options: RequestInit = {
    method: 'get',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return (await response.json()) as Zine[]
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}
...

export async function deleteShipment(fetch: Fetch, query: { id: number }): Promise<void> {
  const url = resolveURL(new URL('/deleteShipment', BASE_URL), { query })
  const options: RequestInit = {
    method: 'delete',
    headers: { 'Content-Type': 'application/json' },
  }

  try {
    const response = await fetch(url, options)
    if (!response.ok) throw new Error(`Request failed with status: ${response.status}`)
    return
  } catch (error) {
    console.error(`received error while fetching url: ${url}`, error)
    return undefined
  }
}

...

@cocowmn cocowmn force-pushed the cocowmn/add-openapi-script branch 2 times, most recently from 98c461b to 8f9fb03 Compare January 13, 2024 14:17
@cocowmn cocowmn force-pushed the cocowmn/add-openapi-script branch from 1bf7e8c to 88ffc59 Compare January 13, 2024 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In review
Development

Successfully merging this pull request may close these issues.

1 participant