Skip to content
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
4 changes: 2 additions & 2 deletions apps/web-console/src/app-layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'
import styled from 'styled-components'

import { useApiData, api } from '@oxide/api'
import { useApi } from '@oxide/api'
import { GlobalNav, OperationList, ProjectList } from '@oxide/ui'
import Wordmark from '../assets/wordmark.svg'

Expand Down Expand Up @@ -56,7 +56,7 @@ const GlobalNavContainer = styled.header`
`

export default ({ children }: AppLayoutProps) => {
const { data: projects } = useApiData(api.apiProjectsGet, {})
const { data: projects } = useApi('apiProjectsGet', {})

return (
<Wrapper>
Expand Down
4 changes: 2 additions & 2 deletions apps/web-console/src/pages/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import { useParams } from 'react-router-dom'
import styled from 'styled-components'

import { useApiData, api } from '@oxide/api'
import { useApi } from '@oxide/api'

import {
Breadcrumbs,
Expand Down Expand Up @@ -74,7 +74,7 @@ const InstancePage = () => {
const breadcrumbs = useBreadcrumbs()
const { projectName, instanceName } = useParams<Params>()

const { data, error } = useApiData(api.apiProjectInstancesGetInstance, {
const { data, error } = useApi('apiProjectInstancesGetInstance', {
instanceName,
projectName,
})
Expand Down
4 changes: 2 additions & 2 deletions apps/web-console/src/pages/instance/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styled from 'styled-components'

import { useParams, Link } from 'react-router-dom'

import { useApiData, api } from '@oxide/api'
import { useApi } from '@oxide/api'
import { Breadcrumbs, PageHeader, TextWithIcon } from '@oxide/ui'
import { useBreadcrumbs } from '../../hooks'

Expand All @@ -20,7 +20,7 @@ const InstancesPage = () => {
const breadcrumbs = useBreadcrumbs()

const { projectName } = useParams<Params>()
const { data } = useApiData(api.apiProjectInstancesGet, { projectName })
const { data } = useApi('apiProjectInstancesGet', { projectName })

if (!data) return <div>loading</div>

Expand Down
6 changes: 3 additions & 3 deletions apps/web-console/src/pages/projects/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styled from 'styled-components'

import { useParams, Link } from 'react-router-dom'

import { useApiData, api } from '@oxide/api'
import { useApi } from '@oxide/api'
import { Breadcrumbs, PageHeader, TextWithIcon } from '@oxide/ui'
import { useBreadcrumbs } from '../../hooks'

Expand All @@ -20,10 +20,10 @@ const ProjectPage = () => {
const breadcrumbs = useBreadcrumbs()

const { projectName } = useParams<Params>()
const { data: project } = useApiData(api.apiProjectsGetProject, {
const { data: project } = useApi('apiProjectsGetProject', {
projectName,
})
const { data: instances } = useApiData(api.apiProjectInstancesGet, {
const { data: instances } = useApi('apiProjectInstancesGet', {
projectName,
})

Expand Down
4 changes: 2 additions & 2 deletions apps/web-console/src/pages/projects/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styled from 'styled-components'

import { Link } from 'react-router-dom'

import { useApiData, api } from '@oxide/api'
import { useApi } from '@oxide/api'
import { useBreadcrumbs } from '../../hooks'
import { Breadcrumbs, PageHeader, TextWithIcon } from '@oxide/ui'

Expand All @@ -14,7 +14,7 @@ const Title = styled(TextWithIcon).attrs({

const ProjectsPage = () => {
const breadcrumbs = useBreadcrumbs()
const { data } = useApiData(api.apiProjectsGet, {})
const { data } = useApi('apiProjectsGet', {})

if (!data) return <div>loading</div>

Expand Down
37 changes: 1 addition & 36 deletions libs/api/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1 @@
import useSWR from 'swr'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Params = Record<string, any>

// TODO: write tests for this
export const sortObj = (obj: Params): Params => {
const sorted: Params = {}
for (const k of Object.keys(obj).sort()) {
sorted[k] = obj[k]
}
return sorted
}

// The first argument to useSWR in the standard use case would be
// the URL to fetch. It is used to uniquely identify the request
// for caching purposes. If multiple components request the same
// thing at the same time, SWR will only make one HTTP request.
// Because we have a generated client library, we do not have URLs.
// Instead, we have function names and parameter objects. But object
// literals do not have referential stability across renders, so we
// have to use JSON.stringify to turn the params into a stable key.
// We also sort the keys in the params object so that { a: 1, b: 2 }
// and { b: 2, a: 1 } are considered equivalent. SWR accepts an array
// of strings as well as a single string.
export function useApiData<P extends Params, R>(
method: (p: P) => Promise<R>,
params: P
) {
if (process.env.NODE_ENV === 'development' && method.name === '') {
throw new Error('API method must have a name')
}

const paramsStr = JSON.stringify(sortObj(params))
return useSWR<R>([method.name, paramsStr], () => method(params))
}
export { getUseApi } from './use-api-data'
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sortObj } from './index'
import { sortObj } from './use-api-data'

describe('sortObj', () => {
it('sorts object keys alphabetically', () => {
Expand Down
79 changes: 79 additions & 0 deletions libs/api/hooks/use-api-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import useSWR from 'swr'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Params = Record<string, any>

export const sortObj = (obj: Params): Params => {
const sorted: Params = {}
for (const k of Object.keys(obj).sort()) {
sorted[k] = obj[k]
}
return sorted
}

// https://github.com/piotrwitek/utility-types/tree/df2502e#pickbyvaluet-valuetype
type PickByValue<T, ValueType> = Pick<
T,
{ [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>

/* eslint-disable @typescript-eslint/no-explicit-any */

// given an API object A and a key K where A[K] is a function that takes a
// single argument and returns a promise...

// extract the type of the argument (if it extends Params)
type ReqParams<A, K extends keyof A> = A[K] extends (p: infer P) => Promise<any>
? P extends Params
? P
: never
: never

// extract the type of the value inside the promise
type Response<A, K extends keyof A> = A[K] extends (p: any) => Promise<infer R>
? R
: never

// This all needs explanation. The easiest starting point is what this would
// look like in plain JS, which is quite simple:
//
// const getUseApi = (api) => (method, params) => {
// const paramsStr = JSON.stringify(sortObj(params))
// return useSWR([method, paramsStr], () => api[method](params))
// }
//
// 1. what's up with the JSON.stringify/
//
// The first argument to useSWR in the standard use case would be the URL to
// fetch. It is used to uniquely identify the request for caching purposes. If
// multiple components request the same thing at the same time, SWR will only
// make one HTTP request. Because we have a generated client library, we do not
// have URLs. Instead, we have function names and parameter objects. But object
// literals do not have referential stability across renders, so we have to use
// JSON.stringify to turn the params into a stable key. We also sort the keys in
// the params object so that { a: 1, b: 2 } and { b: 2, a: 1 } are considered
// equivalent. SWR accepts an array of strings as well as a single string.
//
// 2. what's up with the types?
//
// The type situation here is pretty gnarly considering how simple the plain JS
// version is. The difficulty is that we want full type safety, i.e., based on
// the method name passed in, we want the typechecker to check the params and
// annotate the response. PickByValue ensures we only call methods on the API
// object that follow the (params) => Promise<Response> pattern. Then we use the
// inferred type of the key (the method name) to enforce that params match the
// expected params on the named method. Finally we use the Response helper to
// tell useSWR what type to put on the response data.
export function getUseApi<A extends PickByValue<A, (p: any) => Promise<any>>>(
api: A
) {
function useApi<K extends keyof A>(method: K, params: ReqParams<A, K>) {
const paramsStr = JSON.stringify(sortObj(params))
return useSWR<Response<A, K>>([method, paramsStr], () =>
api[method](params)
)
}
return useApi
}

/* eslint-enable @typescript-eslint/no-explicit-any */
18 changes: 4 additions & 14 deletions libs/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './__generated__'
export * from './hooks'
import { getUseApi } from './hooks'

import { DefaultApi, Configuration } from './__generated__'

Expand All @@ -8,16 +7,7 @@ const config =
? new Configuration({ basePath: process.env.API_URL })
: new Configuration({ basePath: '/api' })

export const api = new DefaultApi(config)
const api = new DefaultApi(config)

// the API methods rely on `this` being bound to the API object. in order to
// pass the methods around as arguments without explicitly calling .bind(this)
// every time, we just bind them all right here. TS doesn't like this, so we throw
// an `any` in there, but it's all above board.
Object.getOwnPropertyNames(DefaultApi.prototype)
.filter((prop) => prop.startsWith('api'))
.forEach((prop) => {
const key = prop as keyof DefaultApi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(api as any)[key] = api[key].bind(api)
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good riddance. blech

export const useApi = getUseApi(api)
export * from './__generated__'
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2",
"@types/styled-components": "5.1.4",
"@types/terser-webpack-plugin": "^5.0.3",
"@types/uuid": "^8.3.0",
"@types/webpack": "^4.41.26",
"@types/webpack-dev-server": "^3.11.2",
Expand Down
13 changes: 0 additions & 13 deletions webpack.prod.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'path'
import webpack from 'webpack'
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
import TerserPlugin from 'terser-webpack-plugin'
import sharedConfig from './webpack.shared.config'

const config = {
Expand All @@ -19,18 +18,6 @@ const config = {
}),
new CleanWebpackPlugin(),
],
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
// HACK: temporary fix to make API hook relying on function name work
// in production. remove to decrease bundle size once hook is improved
keep_fnames: true,
},
}),
],
},
}

export default config
46 changes: 0 additions & 46 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3405,14 +3405,6 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.7.tgz#545158342f949e8fd3bfd813224971ecddc3fac4"
integrity sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ==

"@types/terser-webpack-plugin@^5.0.3":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@types/terser-webpack-plugin/-/terser-webpack-plugin-5.0.3.tgz#9194c24dee3a9d5dcfd67b58edffc1d66653d16b"
integrity sha512-Ef60BOY9hV+yXjkMCuJI17cu1R8/H31n5Rnt1cElJFyBSkbRV3UWyBIYn8YpijsOG05R4bZf3G2azyBHkksu/A==
dependencies:
terser "^5.3.8"
webpack "^5.1.0"

"@types/through@*":
version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
Expand Down Expand Up @@ -13598,15 +13590,6 @@ terser@^4.1.2, terser@^4.6.3, terser@^4.8.0:
source-map "~0.6.1"
source-map-support "~0.5.12"

terser@^5.3.8:
version "5.6.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.1.tgz#a48eeac5300c0a09b36854bf90d9c26fb201973c"
integrity sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw==
dependencies:
commander "^2.20.0"
source-map "~0.7.2"
source-map-support "~0.5.19"

terser@^5.5.1:
version "5.5.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.1.tgz#540caa25139d6f496fdea056e414284886fb2289"
Expand Down Expand Up @@ -14626,35 +14609,6 @@ webpack@4:
watchpack "^1.7.4"
webpack-sources "^1.4.1"

webpack@^5.1.0:
version "5.30.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.30.0.tgz#07d87c182a060e0c2491062f3dc0edc85a29d884"
integrity sha512-Zr9NIri5yzpfmaMea2lSMV1UygbW0zQsSlGLMgKUm63ACXg6alhd1u4v5UBSBjzYKXJN6BNMGVM7w165e7NxYA==
dependencies:
"@types/eslint-scope" "^3.7.0"
"@types/estree" "^0.0.46"
"@webassemblyjs/ast" "1.11.0"
"@webassemblyjs/wasm-edit" "1.11.0"
"@webassemblyjs/wasm-parser" "1.11.0"
acorn "^8.0.4"
browserslist "^4.14.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.7.0"
es-module-lexer "^0.4.0"
eslint-scope "^5.1.1"
events "^3.2.0"
glob-to-regexp "^0.4.1"
graceful-fs "^4.2.4"
json-parse-better-errors "^1.0.2"
loader-runner "^4.2.0"
mime-types "^2.1.27"
neo-async "^2.6.2"
schema-utils "^3.0.0"
tapable "^2.1.1"
terser-webpack-plugin "^5.1.1"
watchpack "^2.0.0"
webpack-sources "^2.1.1"

webpack@^5.26.0:
version "5.26.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.26.0.tgz#269841ed7b5c6522221aa27f7efceda04c8916d7"
Expand Down