Skip to content

Commit

Permalink
Use custom API client generator (#618)
Browse files Browse the repository at this point in the history
* generate client with new generator

* passify tests and types

* actually it's npm install in oxide.ts/generator

* update omicron to bring in full snakeification

* automatically update packer-id

* regen with latest generator version

* serialize mock api responses as snake case (ugh doesn't work)

* upgrade msw for bugfix: port was showing up in request params

mswjs/msw#1036

* fix all problems in universe

* just use never as empty response body type

* use API JSON types, which are cool except they clutter up the generated client

* do the same thing with a Json helper instead of generated *JSON types

* get Snakify from type-fest. why not

* tests for Json type

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
david-crespo and github-actions[bot] committed Jan 26, 2022
1 parent f4a704a commit 292fd0a
Show file tree
Hide file tree
Showing 28 changed files with 894 additions and 1,764 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/packer.yaml
Expand Up @@ -75,7 +75,7 @@ jobs:
CLOUDFLARE_TOKEN: ${{secrets.CLOUDFLARE_TOKEN}}
SSL_CERT: ${{secrets.SSL_CERT}}
SSL_KEY: ${{secrets.SSL_KEY}}
API_VERSION: 72b8c3ec24a82e2778099e818025b91f335186c8
API_VERSION: a75149c4b84b51647928c2c3484dc4370495f023

# get the image information from gcloud
- name: Get image information
Expand Down
2 changes: 1 addition & 1 deletion app/mockServiceWorker.js
Expand Up @@ -2,7 +2,7 @@
/* tslint:disable */

/**
* Mock Service Worker (0.36.3).
* Mock Service Worker (0.36.7).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/networking/VpcPage/VpcPage.tsx
Expand Up @@ -14,7 +14,7 @@ import { useParams } from 'app/hooks'
import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab'
import { useApiQuery } from '@oxide/api'

const formatDateTime = (s: string) => format(new Date(s), 'MMM d, yyyy H:mm aa')
const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa')

export const VpcPage = () => {
const vpcParams = useParams('orgName', 'projectName', 'vpcName')
Expand Down
12 changes: 6 additions & 6 deletions app/test-utils.tsx
Expand Up @@ -4,6 +4,7 @@ import { render } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { json } from '@oxide/api-mocks'
import 'whatwg-fetch'
import '@testing-library/jest-dom'

Expand Down Expand Up @@ -39,12 +40,11 @@ export function override(
body: string | Record<string, unknown>
) {
server.use(
rest[method](path, (_req, res, ctx) => {
return res(
ctx.status(status),
typeof body === 'string' ? ctx.text(body) : ctx.json(body)
)
})
rest[method](path, (_req, res, ctx) =>
typeof body === 'string'
? res(ctx.status(status), ctx.text(body))
: res(json(body, status))
)
)
}

Expand Down
12 changes: 6 additions & 6 deletions app/util/errors.spec.ts
Expand Up @@ -3,25 +3,25 @@ import { getParseError, getServerError } from './errors'

const parseError = {
error: {
request_id: '1',
error_code: null,
requestId: '1',
errorCode: null,
message:
'unable to parse body: hello there, you have an error at line 129 column 4',
},
} as ErrorResponse

const alreadyExists = {
error: {
request_id: '2',
error_code: 'ObjectAlreadyExists',
requestId: '2',
errorCode: 'ObjectAlreadyExists',
message: 'whatever',
},
} as ErrorResponse

const unauthorized = {
error: {
request_id: '3',
error_code: 'Forbidden',
requestId: '3',
errorCode: 'Forbidden',
message: "I'm afraid you can't do that, Dave",
},
} as ErrorResponse
Expand Down
2 changes: 1 addition & 1 deletion app/util/errors.ts
Expand Up @@ -21,7 +21,7 @@ export const getServerError = (
codeMap: Record<string, string> = {}
) => {
if (!error) return null
const code = error.error?.error_code
const code = error.error?.errorCode
const codeMsg = code && (codeMap[code] || globalCodeMap[code])
const serverMsg = error.error?.message
return (
Expand Down
4 changes: 4 additions & 0 deletions docs/update-pinned-api.md
Expand Up @@ -4,6 +4,10 @@ There are, in a way, two sources of truth for the omicron version pinned for dep

The primary source of truth, in the sense that it determines what is actually deployed, is the packer image ID in `tools/create_gcp/instance.sh`. Unless that is changed, the API version deployed will not change. But if you want to change the packer image, you have to get a new one to build by first changing the `API_VERSION` env var set in `.github/workflows/packer.yaml`.

## Setup

The API generation script assumes you have `omicron` and `oxide.ts` cloned under the same parent directory as the console. You should also run `npm install` in `oxide.ts/generator`.

## Instructions

1. Update `API_VERSION` in [`packer.yaml`](https://github.com/oxidecomputer/console/blob/c90ac1660273dbee2a2fe5456fc8318057444a13/.github/workflows/packer.yaml#L49) with new Omicron commit hash
Expand Down
13 changes: 7 additions & 6 deletions libs/api-mocks/disk.ts
@@ -1,16 +1,17 @@
import type { Disk, DiskResultsPage } from '@oxide/api'
import type { Json } from './json-type'
import { project } from './project'

export const disk: Disk = {
export const disk: Json<Disk> = {
id: 'disk-id',
name: 'disk-name',
description: "it's a disk",
projectId: project.id,
timeCreated: new Date().toISOString(),
timeModified: new Date().toISOString(),
project_id: project.id,
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
state: { state: 'detached' },
devicePath: '/uh',
device_path: '/uh',
size: 1000,
}

export const disks: DiskResultsPage = { items: [disk] }
export const disks: Json<DiskResultsPage> = { items: [disk] }
2 changes: 1 addition & 1 deletion libs/api-mocks/index.ts
Expand Up @@ -5,5 +5,5 @@ export * from './project'
export * from './session'
export * from './vpc'

export { handlers } from './msw/handlers'
export { handlers, json } from './msw/handlers'
export { resetDb } from './msw/db'
15 changes: 8 additions & 7 deletions libs/api-mocks/instance.ts
@@ -1,18 +1,19 @@
import type { Instance, InstanceResultsPage } from '@oxide/api'
import type { Json } from './json-type'
import { project } from './project'

export const instance: Instance = {
export const instance: Json<Instance> = {
ncpus: 7,
memory: 1024 * 1024 * 256,
name: 'db1',
description: 'an instance',
id: 'abc123',
hostname: 'oxide.com',
projectId: project.id,
runState: 'running',
timeCreated: new Date().toISOString(),
timeModified: new Date().toISOString(),
timeRunStateUpdated: new Date().toISOString(),
project_id: project.id,
run_state: 'running',
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
time_run_state_updated: new Date().toISOString(),
}

export const instances: InstanceResultsPage = { items: [instance] }
export const instances: Json<InstanceResultsPage> = { items: [instance] }
19 changes: 19 additions & 0 deletions libs/api-mocks/json-type.ts
@@ -0,0 +1,19 @@
import type { SnakeCasedPropertiesDeep as Snakify, Simplify } from 'type-fest'

// these are used for turning our nice JS-ified API types back into the original
// API JSON types (snake cased and dates as strings) for use in our mock API

type StringifyDates<T> = T extends Date
? string
: {
[K in keyof T]: T[K] extends Array<infer U>
? Array<StringifyDates<U>>
: StringifyDates<T[K]>
}

/**
* Snake case fields and convert dates to strings. Not intended to be a general
* purpose JSON type!
*/
// Simplify dramatically improves the IDE type hint.
export type Json<B> = Simplify<Snakify<StringifyDates<B>>>
37 changes: 37 additions & 0 deletions libs/api-mocks/json-type.type-spec.ts
@@ -0,0 +1,37 @@
import type { VpcSubnet } from '@oxide/api'
import type { Json } from './json-type'

// Tests of a sort. These expectType calls will fail to typecheck if the types
// are not equal. There's no point in wrapping this in a real test because it
// will always pass.

const expectType = <T>(_value: T) => {}

let val: any // eslint-disable-line @typescript-eslint/no-explicit-any

// just checking :)
expectType<1>(val as 1)
// @ts-expect-error
expectType<1>(val as 2)
// @ts-expect-error
expectType<{ x: string }>(val as { x: number })

expectType<string>(val as Json<Date>)
expectType<number>(val as Json<number>)
expectType<{ x: string; y: number }>(val as Json<{ x: Date; y: number }>)
expectType<{ x: { a_b45_c: string }; z: string[] }>(
val as Json<{ x: { aB45C: Date }; z: Date[] }>
)

type VpcSubnetJSON = {
description: string
id: string
ipv4_block?: string | null
ipv6_block?: string | null
name: string
time_created: string
time_modified: string
vpc_id: string
}

expectType<VpcSubnetJSON>(val as Json<VpcSubnet>)
13 changes: 7 additions & 6 deletions libs/api-mocks/msw/db.ts
Expand Up @@ -6,11 +6,12 @@ import type {
RestContext,
RestRequest,
} from 'msw'
import type { Json } from '../json-type'

import * as mock from '@oxide/api-mocks'
import type { ApiTypes as Api } from '@oxide/api'

export const notFoundErr = { error_code: 'ObjectNotFound' }
export const notFoundErr = { error_code: 'ObjectNotFound' } as const

type Result<T> =
| { ok: T; err: null }
Expand All @@ -33,7 +34,7 @@ export function lookupOrg(
req: Req<OrgParams>,
res: ResponseComposition,
ctx: RestContext
): Result<Api.Organization> {
): Result<Json<Api.Organization>> {
const org = db.orgs.find((o) => o.name === req.params.orgName)
if (!org) {
return { ok: null, err: res(ctx.status(404), ctx.json(notFoundErr)) }
Expand All @@ -45,12 +46,12 @@ export function lookupProject(
req: Req<ProjectParams>,
res: ResponseComposition,
ctx: RestContext
): Result<Api.Project> {
): Result<Json<Api.Project>> {
const org = lookupOrg(req, res, ctx)
if (org.err) return org // has to be the whole result, not just the error

const project = db.projects.find(
(p) => p.organizationId === org.ok.id && p.name === req.params.projectName
(p) => p.organization_id === org.ok.id && p.name === req.params.projectName
)
if (!project) {
return { ok: null, err: res(ctx.status(404), ctx.json(notFoundErr)) }
Expand All @@ -63,12 +64,12 @@ export function lookupVpc(
req: Req<VpcParams>,
res: ResponseComposition,
ctx: RestContext
): Result<Api.Vpc> {
): Result<Json<Api.Vpc>> {
const project = lookupProject(req, res, ctx)
if (project.err) return project // has to be the whole result, not just the error

const vpc = db.vpcs.find(
(p) => p.projectId === project.ok.id && p.name === req.params.vpcName
(p) => p.project_id === project.ok.id && p.name === req.params.vpcName
)
if (!vpc) {
return { ok: null, err: res(ctx.status(404), ctx.json(notFoundErr)) }
Expand Down

1 comment on commit 292fd0a

@vercel
Copy link

@vercel vercel bot commented on 292fd0a Jan 26, 2022

Choose a reason for hiding this comment

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

Please sign in to comment.