WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit f87a867

Browse files
authored
Merge pull request #14 from Sheraff/api-shared-types
2 parents 97e615d + 2d27ebb commit f87a867

27 files changed

+657
-66
lines changed

.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"react-refresh/only-export-components": ["error", { "allowConstantExport": true }],
4242
"no-unused-labels": "off",
4343
"@typescript-eslint/array-type": ["error", { "default": "array-simple" }],
44-
"no-control-regex": "off"
44+
"no-control-regex": "off",
45+
"@typescript-eslint/no-dynamic-delete": "off"
4546
},
4647
"parserOptions": {
4748
"project": ["tsconfig.tools.json"]

README.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,6 @@ pnpm analyze # bundle size analysis
104104

105105
## TODO
106106

107-
- finish auth
108-
- better utils for "protected" stuff (client & server)
109-
- add endpoint for "which providers are already associated with current user"
110-
- on the client side, this can be used to hide the "associate" button for these providers
111-
- on the client side, this gives us the opportunity to make an "online-only" component demo
112-
- this is a good opportunity to make a trpc-like fullstack type-safe query system
113107
- database
114108
- figure out migrations story
115109
- cleanup bento

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
},
2323
"dependencies": {
2424
"assets": "workspace:assets@*",
25+
"server": "workspace:server@*",
2526
"shared": "workspace:shared@*"
2627
},
2728
"devDependencies": {

client/src/api/helpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ClientDefinition } from "server/api/helpers"
2+
import type { StringAsNumber } from "shared/typeHelpers"
3+
4+
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
5+
type Fail = 1 | 3 | 4 | 5
6+
7+
type SuccessCodes = StringAsNumber<`2${Digit}${Digit}`> | "2xx"
8+
type FailCodes = StringAsNumber<`${Fail}${Digit}${Digit}`> | `${Fail}xx`
9+
10+
export type DefResponse<Def extends ClientDefinition> = Def["schema"]["Reply"][SuccessCodes &
11+
keyof Def["schema"]["Reply"]]
12+
export type DefError<Def extends ClientDefinition> = Def["schema"]["Reply"][FailCodes &
13+
keyof Def["schema"]["Reply"]]
14+
15+
export function makeHeaders(data?: Record<string, unknown>) {
16+
if (!data) return undefined
17+
// TS doesn't like Headers being constructed with arbitrary data, but `Headers` will stringify every value.
18+
const headers = new Headers(data as Record<string, string>)
19+
return headers
20+
}
21+
22+
export function getKey(url: string, method: string, data?: object | null) {
23+
return [url.split("/"), method, data ?? {}]
24+
}

client/src/api/useApiMutation.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { type UseMutationOptions, useMutation } from "@tanstack/react-query"
2+
import { makeHeaders, type DefError, type DefResponse, getKey } from "client/api/helpers"
3+
import type { ClientDefinition } from "server/api/helpers"
4+
import { replaceParams } from "shared/replaceParams"
5+
import type { Prettify } from "shared/typeHelpers"
6+
7+
type MutData = "Querystring" | "Params" | "Headers" | "Body"
8+
9+
type DefVariables<Def extends ClientDefinition> =
10+
object extends Prettify<Pick<Def["schema"], MutData & keyof Def["schema"]>>
11+
? null
12+
: Prettify<Pick<Def["schema"], MutData & keyof Def["schema"]>>
13+
14+
type MissingKeys<from, provided extends Partial<from>> = from extends object
15+
? object extends provided
16+
? from
17+
: Pick<
18+
from,
19+
{
20+
[key in keyof from]: key extends keyof provided ? never : key
21+
}[keyof from]
22+
>
23+
: void
24+
25+
export function useApiMutation<
26+
Def extends ClientDefinition,
27+
Early extends Partial<DefVariables<Def>> = object,
28+
>(
29+
{ url, method }: Def,
30+
early?: Early | null,
31+
options?: Omit<
32+
UseMutationOptions<
33+
Prettify<DefResponse<Def>>,
34+
Prettify<DefError<Def>>,
35+
Prettify<MissingKeys<DefVariables<Def>, Early>>
36+
>,
37+
"mutationKey" | "mutationFn"
38+
>
39+
) {
40+
return useMutation<
41+
Prettify<DefResponse<Def>>,
42+
Prettify<DefError<Def>>,
43+
Prettify<MissingKeys<DefVariables<Def>, Early>>
44+
>({
45+
...options,
46+
mutationKey: getKey(url, method, early),
47+
async mutationFn(lazy: Prettify<MissingKeys<DefVariables<Def>, Early>>) {
48+
const data = { ...early, ...lazy } as unknown as DefVariables<Def>
49+
// Params are placed in the pathname
50+
const replaced = replaceParams(url, data?.Params ?? {})
51+
// Querystring is placed in the search params
52+
const withBody = data?.Querystring
53+
? `${replaced}?${new URLSearchParams(data.Querystring).toString()}`
54+
: replaced
55+
// Body is stringified and placed in the body
56+
const body = data?.Body ? JSON.stringify(data.Body) : undefined
57+
// Headers are placed in the headers
58+
const headers =
59+
makeHeaders(data?.Headers as Record<string, unknown>) ?? body
60+
? new Headers()
61+
: undefined
62+
if (body) headers?.set("Content-Type", "application/json")
63+
const response = await fetch(withBody, {
64+
method,
65+
headers,
66+
body,
67+
})
68+
const result = await response.json().catch(() => {})
69+
if (response.status < 200 || response.status >= 300) throw result
70+
return result as Prettify<DefResponse<Def>>
71+
},
72+
})
73+
}

client/src/api/useApiQuery.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
2+
import { makeHeaders, type DefError, type DefResponse, getKey } from "client/api/helpers"
3+
import type { ClientDefinition } from "server/api/helpers"
4+
import { replaceParams } from "shared/replaceParams"
5+
import type { Prettify } from "shared/typeHelpers"
6+
7+
type GetData = "Querystring" | "Params" | "Headers"
8+
9+
export function useApiQuery<Def extends ClientDefinition, T = Prettify<DefResponse<Def>>>(
10+
{ url, method }: Def,
11+
data: object extends Pick<Def["schema"], GetData & keyof Def["schema"]>
12+
? null
13+
: Prettify<Pick<Def["schema"], GetData & keyof Def["schema"]>>,
14+
options?: Omit<
15+
UseQueryOptions<Prettify<DefResponse<Def>>, Prettify<DefError<Def>>, T>,
16+
"queryKey" | "queryFn"
17+
>
18+
) {
19+
return useQuery<Prettify<DefResponse<Def>>, Prettify<DefError<Def>>, T>({
20+
...options,
21+
queryKey: getKey(url, method, data),
22+
async queryFn() {
23+
// Params are placed in the pathname
24+
const replaced = replaceParams(url, data?.Params ?? {})
25+
// Querystring is placed in the search params
26+
const withBody = data?.Querystring
27+
? `${replaced}?${new URLSearchParams(data.Querystring).toString()}`
28+
: replaced
29+
// Headers are placed in the headers
30+
const headers = makeHeaders(data?.Headers as Record<string, unknown>)
31+
const response = await fetch(withBody, { method, headers })
32+
const result = await response.json().catch(() => {})
33+
if (response.status < 200 || response.status >= 300) throw result
34+
return result as Prettify<DefResponse<Def>>
35+
},
36+
})
37+
}

client/src/components/ApiDemo.tsx

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,46 @@
1-
import { useEffect, useState } from "react"
1+
import { useApiMutation } from "client/api/useApiMutation"
2+
import { useApiQuery } from "client/api/useApiQuery"
3+
import { Button } from "client/components/Button/Button"
4+
import { definition as openDefinition } from "server/api/open"
5+
import { definition as protectedDefinition } from "server/api/protected"
6+
import { definition as saveDefinition } from "server/api/save"
27

38
export function ApiDemo() {
4-
const [protectedRes, setProtectedRes] = useState<unknown>()
5-
useEffect(() => {
6-
fetch("/api/protected")
7-
.then((res) => res.json())
8-
.then(setProtectedRes)
9-
.catch((e) => {
10-
console.error(e)
11-
setProtectedRes({ error: String(e) })
12-
})
13-
}, [])
9+
const open = useApiQuery(openDefinition, {
10+
Headers: { "x-id": "123" },
11+
Querystring: { id: "42" },
12+
})
1413

15-
const [openRes, setOpenRes] = useState<unknown>()
16-
useEffect(() => {
17-
fetch("/api/hello")
18-
.then((res) => res.json())
19-
.then(setOpenRes)
20-
.catch((e) => {
21-
console.error(e)
22-
setOpenRes({ error: String(e) })
23-
})
24-
}, [])
14+
const secret = useApiQuery(protectedDefinition, null, {
15+
retry: false,
16+
})
17+
18+
const save = useApiMutation(saveDefinition, null, {
19+
onSuccess(data, variables, context) {
20+
console.log("Saved", data, variables, context)
21+
setTimeout(() => save.reset(), 1000)
22+
},
23+
})
2524

2625
return (
2726
<>
2827
<h2>Open</h2>
29-
<pre>{openRes ? JSON.stringify(openRes, null, 2) : " \n loading\n "}</pre>
28+
<pre>{open.data ? JSON.stringify(open.data, null, 2) : " \n loading\n "}</pre>
3029
<h2>Protected</h2>
31-
<pre>{protectedRes ? JSON.stringify(protectedRes, null, 2) : " \n loading\n "}</pre>
30+
<pre>
31+
{secret.error
32+
? JSON.stringify(secret.error, null, 2)
33+
: secret.data
34+
? JSON.stringify(secret.data, null, 2)
35+
: " \n loading\n "}
36+
</pre>
37+
<h2>Mutation</h2>
38+
<Button
39+
disabled={save.isPending || save.isSuccess}
40+
onClick={() => save.mutate({ Body: { hello: "world", moo: 42 } })}
41+
>
42+
{save.isPending ? "mutating..." : save.isSuccess ? "ok" : "Save"}
43+
</Button>
3244
</>
3345
)
3446
}

client/src/components/Button/Button.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
touch-action: manipulation;
1717
white-space: nowrap;
1818
margin-right: 10px;
19+
20+
&[disabled] {
21+
cursor: not-allowed;
22+
opacity: 0.5;
23+
}
1924
}
2025

2126
.dark {

client/src/components/UserAccountDemo.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { useApiQuery } from "client/api/useApiQuery"
12
import type { Provider } from "client/auth/providers"
23
import { useAuthContext } from "client/auth/useAuthContext"
34
import { Button } from "client/components/Button/Button"
45
import { Divider } from "client/components/Divider/Divider"
6+
import { definition as accountsDefinition } from "server/api/accounts"
57

68
export function UserAccountDemo() {
79
const auth = useAuthContext()
@@ -116,6 +118,7 @@ function LoggedIn({
116118
linkAccount: (provider: string) => Promise<void>
117119
providers: Provider[]
118120
}) {
121+
const accounts = useApiQuery(accountsDefinition, null)
119122
return (
120123
<>
121124
<div>Logged in as {userId}</div>
@@ -130,6 +133,7 @@ function LoggedIn({
130133
onClick={() => void linkAccount(provider.key)}
131134
dark
132135
style={{ backgroundColor: provider.color }}
136+
disabled={accounts.data?.accounts.includes(provider.key)}
133137
>
134138
{provider.name}
135139
</Button>

client/tsconfig.app.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"src/**/*.test.ts",
1919
"types/*.d.ts",
2020
"../types/*.d.ts",
21-
"../shared/src/**/*.ts"
21+
"../shared/src/**/*.ts",
22+
"../server/src/api/**/*.ts"
2223
]
2324
}

0 commit comments

Comments
 (0)