Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f0b17b6
WIP shared API types w/ inference, validation, E2E type safety
Sheraff Feb 10, 2024
263151e
using a proxy for "go to def". Basics are working.
Sheraff Feb 11, 2024
0fcbc14
Merge branch 'main' into api-shared-types
Sheraff Feb 11, 2024
9809c4c
cleanup
Sheraff Feb 11, 2024
6095857
Merge branch 'main' into api-shared-types
Sheraff Feb 11, 2024
34d29b4
Merge branch 'main' into api-shared-types
Sheraff Feb 11, 2024
dcd527b
fix knip w/ api router namespaced imports
Sheraff Feb 11, 2024
e21191b
Merge branch 'main' into api-shared-types
Sheraff Feb 12, 2024
76d3b60
some progress, but this look too complicated, look into smth else?
Sheraff Feb 12, 2024
2739046
minor fix
Sheraff Feb 12, 2024
6c41abe
new implementation proposal
Sheraff Feb 12, 2024
10bed72
Merge branch 'main' into api-shared-types
Sheraff Feb 12, 2024
2becd37
fix spelling
Sheraff Feb 12, 2024
07435ed
new method works, but imports in "shared folder inside server" can on…
Sheraff Feb 12, 2024
6fadf48
add vite aliases in `/client` for `server/` to allow for direct impor…
Sheraff Feb 12, 2024
f888f9f
avoid importing json schema on client, only need types
Sheraff Feb 12, 2024
e763f15
cleanup, do we still have hot reloading on api routes?
Sheraff Feb 12, 2024
0d8a10e
fully typed useApiQuery
Sheraff Feb 12, 2024
941ff75
allow ts imports of server/src/api from client
Sheraff Feb 12, 2024
25f62fe
cleanup
Sheraff Feb 12, 2024
79e84fb
add useApiMutation hook
Sheraff Feb 13, 2024
9e86245
create /api/accounts endpoint for UserAccountDemo
Sheraff Feb 13, 2024
1b0bca8
better server DX: single PURE tag, no schema shipped to the client
Sheraff Feb 13, 2024
6fd6531
rename auth protected utils
Sheraff Feb 13, 2024
0a3abd6
update TODO
Sheraff Feb 13, 2024
a14c2e4
Merge branch 'main' into api-shared-types
Sheraff Feb 13, 2024
8c3a6ed
restore api unit tests
Sheraff Feb 13, 2024
2d27ebb
Update version to 0.0.0-alpha.7 in package.json
Sheraff Feb 13, 2024
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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"react-refresh/only-export-components": ["error", { "allowConstantExport": true }],
"no-unused-labels": "off",
"@typescript-eslint/array-type": ["error", { "default": "array-simple" }],
"no-control-regex": "off"
"no-control-regex": "off",
"@typescript-eslint/no-dynamic-delete": "off"
},
"parserOptions": {
"project": ["tsconfig.tools.json"]
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,6 @@ pnpm analyze # bundle size analysis

## TODO

- finish auth
- better utils for "protected" stuff (client & server)
- add endpoint for "which providers are already associated with current user"
- on the client side, this can be used to hide the "associate" button for these providers
- on the client side, this gives us the opportunity to make an "online-only" component demo
- this is a good opportunity to make a trpc-like fullstack type-safe query system
- database
- figure out migrations story
- cleanup bento
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"assets": "workspace:assets@*",
"server": "workspace:server@*",
"shared": "workspace:shared@*"
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions client/src/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ClientDefinition } from "server/api/helpers"
import type { StringAsNumber } from "shared/typeHelpers"

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
type Fail = 1 | 3 | 4 | 5

type SuccessCodes = StringAsNumber<`2${Digit}${Digit}`> | "2xx"
type FailCodes = StringAsNumber<`${Fail}${Digit}${Digit}`> | `${Fail}xx`

export type DefResponse<Def extends ClientDefinition> = Def["schema"]["Reply"][SuccessCodes &
keyof Def["schema"]["Reply"]]
export type DefError<Def extends ClientDefinition> = Def["schema"]["Reply"][FailCodes &
keyof Def["schema"]["Reply"]]

export function makeHeaders(data?: Record<string, unknown>) {
if (!data) return undefined
// TS doesn't like Headers being constructed with arbitrary data, but `Headers` will stringify every value.
const headers = new Headers(data as Record<string, string>)
return headers
}

export function getKey(url: string, method: string, data?: object | null) {
return [url.split("/"), method, data ?? {}]
}
73 changes: 73 additions & 0 deletions client/src/api/useApiMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { type UseMutationOptions, useMutation } from "@tanstack/react-query"
import { makeHeaders, type DefError, type DefResponse, getKey } from "client/api/helpers"
import type { ClientDefinition } from "server/api/helpers"
import { replaceParams } from "shared/replaceParams"
import type { Prettify } from "shared/typeHelpers"

type MutData = "Querystring" | "Params" | "Headers" | "Body"

type DefVariables<Def extends ClientDefinition> =
object extends Prettify<Pick<Def["schema"], MutData & keyof Def["schema"]>>
? null
: Prettify<Pick<Def["schema"], MutData & keyof Def["schema"]>>

type MissingKeys<from, provided extends Partial<from>> = from extends object
? object extends provided
? from
: Pick<
from,
{
[key in keyof from]: key extends keyof provided ? never : key
}[keyof from]
>
: void

export function useApiMutation<
Def extends ClientDefinition,
Early extends Partial<DefVariables<Def>> = object,
>(
{ url, method }: Def,
early?: Early | null,
options?: Omit<
UseMutationOptions<
Prettify<DefResponse<Def>>,
Prettify<DefError<Def>>,
Prettify<MissingKeys<DefVariables<Def>, Early>>
>,
"mutationKey" | "mutationFn"
>
) {
return useMutation<
Prettify<DefResponse<Def>>,
Prettify<DefError<Def>>,
Prettify<MissingKeys<DefVariables<Def>, Early>>
>({
...options,
mutationKey: getKey(url, method, early),
async mutationFn(lazy: Prettify<MissingKeys<DefVariables<Def>, Early>>) {
const data = { ...early, ...lazy } as unknown as DefVariables<Def>
// Params are placed in the pathname
const replaced = replaceParams(url, data?.Params ?? {})
// Querystring is placed in the search params
const withBody = data?.Querystring
? `${replaced}?${new URLSearchParams(data.Querystring).toString()}`
: replaced
// Body is stringified and placed in the body
const body = data?.Body ? JSON.stringify(data.Body) : undefined
// Headers are placed in the headers
const headers =
makeHeaders(data?.Headers as Record<string, unknown>) ?? body
? new Headers()
: undefined
if (body) headers?.set("Content-Type", "application/json")
const response = await fetch(withBody, {
method,
headers,
body,
})
const result = await response.json().catch(() => {})
if (response.status < 200 || response.status >= 300) throw result
return result as Prettify<DefResponse<Def>>
},
})
}
37 changes: 37 additions & 0 deletions client/src/api/useApiQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { makeHeaders, type DefError, type DefResponse, getKey } from "client/api/helpers"
import type { ClientDefinition } from "server/api/helpers"
import { replaceParams } from "shared/replaceParams"
import type { Prettify } from "shared/typeHelpers"

type GetData = "Querystring" | "Params" | "Headers"

export function useApiQuery<Def extends ClientDefinition, T = Prettify<DefResponse<Def>>>(
{ url, method }: Def,
data: object extends Pick<Def["schema"], GetData & keyof Def["schema"]>
? null
: Prettify<Pick<Def["schema"], GetData & keyof Def["schema"]>>,
options?: Omit<
UseQueryOptions<Prettify<DefResponse<Def>>, Prettify<DefError<Def>>, T>,
"queryKey" | "queryFn"
>
) {
return useQuery<Prettify<DefResponse<Def>>, Prettify<DefError<Def>>, T>({
...options,
queryKey: getKey(url, method, data),
async queryFn() {
// Params are placed in the pathname
const replaced = replaceParams(url, data?.Params ?? {})
// Querystring is placed in the search params
const withBody = data?.Querystring
? `${replaced}?${new URLSearchParams(data.Querystring).toString()}`
: replaced
// Headers are placed in the headers
const headers = makeHeaders(data?.Headers as Record<string, unknown>)
const response = await fetch(withBody, { method, headers })
const result = await response.json().catch(() => {})
if (response.status < 200 || response.status >= 300) throw result
return result as Prettify<DefResponse<Def>>
},
})
}
58 changes: 35 additions & 23 deletions client/src/components/ApiDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,46 @@
import { useEffect, useState } from "react"
import { useApiMutation } from "client/api/useApiMutation"
import { useApiQuery } from "client/api/useApiQuery"
import { Button } from "client/components/Button/Button"
import { definition as openDefinition } from "server/api/open"
import { definition as protectedDefinition } from "server/api/protected"
import { definition as saveDefinition } from "server/api/save"

export function ApiDemo() {
const [protectedRes, setProtectedRes] = useState<unknown>()
useEffect(() => {
fetch("/api/protected")
.then((res) => res.json())
.then(setProtectedRes)
.catch((e) => {
console.error(e)
setProtectedRes({ error: String(e) })
})
}, [])
const open = useApiQuery(openDefinition, {
Headers: { "x-id": "123" },
Querystring: { id: "42" },
})

const [openRes, setOpenRes] = useState<unknown>()
useEffect(() => {
fetch("/api/hello")
.then((res) => res.json())
.then(setOpenRes)
.catch((e) => {
console.error(e)
setOpenRes({ error: String(e) })
})
}, [])
const secret = useApiQuery(protectedDefinition, null, {
retry: false,
})

const save = useApiMutation(saveDefinition, null, {
onSuccess(data, variables, context) {
console.log("Saved", data, variables, context)
setTimeout(() => save.reset(), 1000)
},
})

return (
<>
<h2>Open</h2>
<pre>{openRes ? JSON.stringify(openRes, null, 2) : " \n loading\n "}</pre>
<pre>{open.data ? JSON.stringify(open.data, null, 2) : " \n loading\n "}</pre>
<h2>Protected</h2>
<pre>{protectedRes ? JSON.stringify(protectedRes, null, 2) : " \n loading\n "}</pre>
<pre>
{secret.error
? JSON.stringify(secret.error, null, 2)
: secret.data
? JSON.stringify(secret.data, null, 2)
: " \n loading\n "}
</pre>
<h2>Mutation</h2>
<Button
disabled={save.isPending || save.isSuccess}
onClick={() => save.mutate({ Body: { hello: "world", moo: 42 } })}
>
{save.isPending ? "mutating..." : save.isSuccess ? "ok" : "Save"}
</Button>
</>
)
}
5 changes: 5 additions & 0 deletions client/src/components/Button/Button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
touch-action: manipulation;
white-space: nowrap;
margin-right: 10px;

&[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
}

.dark {
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/UserAccountDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useApiQuery } from "client/api/useApiQuery"
import type { Provider } from "client/auth/providers"
import { useAuthContext } from "client/auth/useAuthContext"
import { Button } from "client/components/Button/Button"
import { Divider } from "client/components/Divider/Divider"
import { definition as accountsDefinition } from "server/api/accounts"

export function UserAccountDemo() {
const auth = useAuthContext()
Expand Down Expand Up @@ -116,6 +118,7 @@ function LoggedIn({
linkAccount: (provider: string) => Promise<void>
providers: Provider[]
}) {
const accounts = useApiQuery(accountsDefinition, null)
return (
<>
<div>Logged in as {userId}</div>
Expand All @@ -130,6 +133,7 @@ function LoggedIn({
onClick={() => void linkAccount(provider.key)}
dark
style={{ backgroundColor: provider.color }}
disabled={accounts.data?.accounts.includes(provider.key)}
>
{provider.name}
</Button>
Expand Down
3 changes: 2 additions & 1 deletion client/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"src/**/*.test.ts",
"types/*.d.ts",
"../types/*.d.ts",
"../shared/src/**/*.ts"
"../shared/src/**/*.ts",
"../server/src/api/**/*.ts"
]
}
1 change: 1 addition & 0 deletions client/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"types/*.d.ts",
"../types/*.d.ts",
"../shared/src/**/*.ts",
"../server/src/api/**/*.ts",
"tsconfig.json",
"tsconfig.app.json"
],
Expand Down
2 changes: 2 additions & 0 deletions client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export default defineConfig({
alias: {
// use alias to avoid having "client" as a package dependency of "client"
"client/": `${normalizePath(__dirname)}/src/`,
// allow imports from "server" to resolve to the server folder, despite tsconfig aliases
"server/": `${normalizePath(__dirname)}/../server/src/`,
},
},
server: {
Expand Down
3 changes: 2 additions & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://unpkg.com/knip@4/schema.json",
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignoreDependencies": [
// used by fastify, with just a string reference, no import
"pino-pretty",
Expand All @@ -12,5 +12,6 @@
// knip doesn't grab the "sub-projects" of main tsconfig files (like tsconfig.app.json), so it never discovers .d.ts files
"**/types/*.d.ts"
],
"include": ["nsExports", "nsTypes"],
"includeEntryExports": true
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "repo",
"version": "0.0.0-alpha.6",
"version": "0.0.0-alpha.7",
"license": "MIT",
"private": true,
"repository": {
Expand Down Expand Up @@ -73,6 +73,7 @@
"knip": "^5.0.1",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "5.12.0",
"json-schema-to-ts": "3.0.0",
"tsx": "4.7.1",
"turbo": "1.12.3",
"typed-css-modules": "^0.9.1",
Expand Down
Loading