Skip to content

Commit 945d777

Browse files
authored
Add full-stack compile-time type safety (#1090)
1 parent 5be9d1f commit 945d777

File tree

128 files changed

+1227
-529
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

128 files changed

+1227
-529
lines changed

waspc/data/Generator/templates/react-app/src/actions/_action.js

-7
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{{={= =}=}}
2+
import { createAction } from './core'
3+
{=& operationTypeImportStmt =}
4+
5+
const action = createAction<{= operationTypeName =}>(
6+
'{= actionRoute =}',
7+
{=& entitiesArray =},
8+
)
9+
10+
export default action
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
import { Action } from '.'
1+
import { type Action } from '.'
2+
import type { Expand, _Awaited } from '../universal/types'
23

3-
export function createAction<Input, Output>(actionRoute: string, entitiesUsed: unknown[]): Action<Input, Output>
4+
export function createAction<BackendAction extends GenericBackendAction>(
5+
actionRoute: string,
6+
entitiesUsed: unknown[]
7+
): ActionFor<BackendAction>
8+
9+
type ActionFor<BackendAction extends GenericBackendAction> = Expand<
10+
Action<Parameters<BackendAction>[0], _Awaited<ReturnType<BackendAction>>>
11+
>
12+
13+
type GenericBackendAction = (args: never, context: any) => Promise<unknown>

waspc/data/Generator/templates/react-app/src/actions/index.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import {
55
UseMutationOptions,
66
useQueryClient,
77
} from '@tanstack/react-query'
8-
import { Query } from '../queries';
8+
import { type Query } from '../queries';
99

10-
export type Action<Input, Output> = (args?: Input) => Promise<Output>;
10+
export type Action<Input, Output> =
11+
[Input] extends [never] ?
12+
(args?: unknown) => Promise<Output> :
13+
(args: Input) => Promise<Output>
1114

1215
/**
1316
* An options object passed into the `useAction` hook and used to enhance the

waspc/data/Generator/templates/react-app/src/api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ api.interceptors.response.use(undefined, (error) => {
4545
* standard format to be further used by the client. It is also assumed that given API
4646
* error has been formatted as implemented by HttpError on the server.
4747
*/
48-
export function handleApiError (error: AxiosError<{ message?: string, data?: unknown }>): void {
48+
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
4949
if (error?.response) {
5050
// If error came from HTTP response, we capture most informative message
5151
// and also add .statusCode information to it.

waspc/data/Generator/templates/react-app/src/auth/pages/createAuthRequiredPage.jsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import React from 'react'
33

44
import { Redirect } from 'react-router-dom'
5-
import useAuth from '../useAuth.js'
5+
import useAuth from '../useAuth'
66

77

88
const createAuthRequiredPage = (Page) => {

waspc/data/Generator/templates/react-app/src/auth/useAuth.js renamed to waspc/data/Generator/templates/react-app/src/auth/useAuth.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import { deserialize as superjsonDeserialize } from 'superjson'
12
import { useQuery } from '../queries'
23
import api, { handleApiError } from '../api'
34
import { HttpMethod } from '../types'
5+
// todo(filip): turn into a proper import
6+
import { type SanitizedUser as User } from '../../../server/src/_types/'
47

5-
export default function useAuth(queryFnArgs, config) {
8+
export default function useAuth(queryFnArgs?: unknown, config?: any) {
69
return useQuery(getMe, queryFnArgs, config)
710
}
811

9-
export async function getMe() {
12+
export async function getMe(): Promise<User | null> {
1013
try {
1114
const response = await api.get('/auth/me')
1215

13-
return response.data
16+
return superjsonDeserialize(response.data)
1417
} catch (error) {
1518
if (error.response?.status === 401) {
1619
return null

waspc/data/Generator/templates/react-app/src/operations/index.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import api, { handleApiError } from '../api'
22
import { HttpMethod } from '../types'
3+
import {
4+
serialize as superjsonSerialize,
5+
deserialize as superjsonDeserialize,
6+
} from 'superjson'
37

48
export type OperationRoute = { method: HttpMethod, path: string }
59

610
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
711
try {
8-
const response = await api.post(operationRoute.path, args)
9-
return response.data
12+
const superjsonArgs = superjsonSerialize(args)
13+
const response = await api.post(operationRoute.path, superjsonArgs)
14+
return superjsonDeserialize(response.data)
1015
} catch (error) {
1116
handleApiError(error)
1217
}

waspc/data/Generator/templates/react-app/src/operations/resources.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
1111
/**
1212
* Remembers that specified query is using specified resources.
1313
* If called multiple times for same query, resources are added, not reset.
14-
* @param {string} queryCacheKey - Unique key under used to identify query in the cache.
14+
* @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
1515
* @param {string[]} resources - Names of resources that query is using.
1616
*/
1717
export function addResourcesUsedByQuery(queryCacheKey, resources) {

waspc/data/Generator/templates/react-app/src/queries/_query.js

-7
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{={= =}=}}
2+
import { createQuery } from './core'
3+
{=& operationTypeImportStmt =}
4+
5+
6+
const query = createQuery<{= operationTypeName =}>(
7+
'{= queryRoute =}',
8+
{=& entitiesArray =},
9+
)
10+
11+
export default query
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
import { type Query } from './index'
1+
import { type Query } from '.'
2+
import type { Expand, _Awaited } from '../universal/types'
23

3-
export function createQuery<Input, Output>(queryRoute: string, entitiesUsed: any[]): Query<Input, Output>
4+
export function createQuery<BackendQuery extends GenericBackendQuery>(
5+
queryRoute: string,
6+
entitiesUsed: any[]
7+
): QueryFor<BackendQuery>
8+
9+
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
10+
Query<Parameters<BackendQuery>[0], _Awaited<ReturnType<BackendQuery>>>
11+
>
12+
13+
type GenericBackendQuery = (args: never, context: any) => Promise<unknown>
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import { UseQueryResult } from "@tanstack/react-query";
22

3-
import { type HttpMethod } from "../types";
4-
53
export type Query<Input, Output> = {
6-
(args: Input): Promise<Output>
7-
queryCacheKey: string[]
8-
route: { method: HttpMethod, path: string }
4+
(queryCacheKey: string[], args: Input): Promise<Output>
95
}
106

11-
export function useQuery<Input, Output, Error = unknown>(
7+
export function useQuery<Input, Output>(
128
queryFn: Query<Input, Output>,
139
queryFnArgs?: Input, options?: any
1410
): UseQueryResult<Output, Error>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ReactElement } from 'react'
2+
import { serialize as superjsonSerialize } from 'superjson'
23
import { rest, type ResponseResolver, type RestContext } from 'msw'
34
import { setupServer, type SetupServer } from 'msw/node'
45
import { BrowserRouter as Router } from 'react-router-dom'
@@ -9,27 +10,39 @@ import { Query } from '../../queries'
910
import config from '../../config'
1011
import { HttpMethod } from '../../types'
1112

13+
export type Route = { method: HttpMethod; path: string }
14+
15+
export type MockQuery = <Input, Output, MockOutput extends Output>(
16+
query: Query<Input, Output>,
17+
resJson: MockOutput
18+
) => void
19+
20+
export type MockApi = (route: Route, resJson: unknown) => void
21+
1222
// Inspired by the Tanstack React Query helper:
1323
// https://github.com/TanStack/query/blob/4ae99561ca3383d6de3f4aad656a49ba4a17b57a/packages/react-query/src/__tests__/utils.tsx#L7-L26
1424
export function renderInContext(ui: ReactElement): RenderResult {
1525
const client = new QueryClient()
1626
const { rerender, ...result } = render(
17-
<QueryClientProvider client={client}><Router>{ui}</Router></QueryClientProvider>
27+
<QueryClientProvider client={client}>
28+
<Router>{ui}</Router>
29+
</QueryClientProvider>
1830
)
1931
return {
2032
...result,
2133
rerender: (rerenderUi: ReactElement) =>
2234
rerender(
23-
<QueryClientProvider client={client}><Router>{rerenderUi}</Router></QueryClientProvider>
24-
)
35+
<QueryClientProvider client={client}>
36+
<Router>{rerenderUi}</Router>
37+
</QueryClientProvider>
38+
),
2539
}
2640
}
2741

28-
type QueryRoute = Query<any, any>['route']
29-
3042
export function mockServer(): {
31-
server: SetupServer,
32-
mockQuery: ({ route }: { route: QueryRoute }, resJson: any) => void
43+
server: SetupServer
44+
mockQuery: MockQuery
45+
mockApi: MockApi
3346
} {
3447
const server: SetupServer = setupServer()
3548

@@ -40,28 +53,41 @@ export function mockServer(): {
4053
})
4154
afterAll(() => server.close())
4255

43-
function mockQuery({ route }: { route: QueryRoute }, resJson: any): void {
44-
if (!Object.values(HttpMethod).includes(route.method)) {
45-
throw new Error(`Unsupported query method for mocking: ${route.method}. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`)
46-
}
56+
const mockQuery: MockQuery = (query, mockData) => {
57+
const route = (query as unknown as { route: Route }).route
58+
mockRoute(server, route, (_req, res, ctx) =>
59+
res(ctx.json(superjsonSerialize(mockData)))
60+
)
61+
}
62+
63+
const mockApi: MockApi = (route, mockData) => {
64+
mockRoute(server, route, (_req, res, ctx) => res(ctx.json(mockData)))
65+
}
66+
67+
return { server, mockQuery, mockApi }
68+
}
4769

48-
const url = `${config.apiUrl}${route.path}`
49-
const responseHandler: ResponseResolver<any, RestContext, any> = (_req, res, ctx) => {
50-
return res(ctx.json(resJson))
51-
}
70+
function mockRoute(
71+
server: SetupServer,
72+
route: Route,
73+
responseHandler: ResponseResolver<any, RestContext, any>
74+
) {
75+
if (!Object.values(HttpMethod).includes(route.method)) {
76+
throw new Error(
77+
`Unsupported query method for mocking: ${
78+
route.method
79+
}. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`
80+
)
81+
}
5282

53-
// NOTE: Technically, we only need to care about POST for Queries
54-
// and GET for the /auth/me route. However, an additional use case
55-
// for this function could be to mock APIs, so more methods are supported.
56-
const handlers: Record<HttpMethod, Parameters<typeof server.use>[0]> = {
57-
[HttpMethod.Get]: rest.get(url, responseHandler),
58-
[HttpMethod.Post]: rest.post(url, responseHandler),
59-
[HttpMethod.Put]: rest.put(url, responseHandler),
60-
[HttpMethod.Delete]: rest.delete(url, responseHandler),
61-
}
83+
const url = `${config.apiUrl}${route.path}`
6284

63-
server.use(handlers[route.method])
85+
const handlers: Record<HttpMethod, Parameters<typeof server.use>[0]> = {
86+
[HttpMethod.Get]: rest.get(url, responseHandler),
87+
[HttpMethod.Post]: rest.post(url, responseHandler),
88+
[HttpMethod.Put]: rest.put(url, responseHandler),
89+
[HttpMethod.Delete]: rest.delete(url, responseHandler),
6490
}
6591

66-
return { server, mockQuery }
92+
server.use(handlers[route.method])
6793
}

waspc/data/Generator/templates/server/src/_types/index.ts

+7-15
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{{={= =}=}}
2+
import { type Expand } from "../universal/types.js";
23
import { type Request, type Response } from 'express'
34
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
45
import prisma from "../dbClient.js"
@@ -72,20 +73,11 @@ type Context<Entities extends _Entity[]> = Expand<{
7273
}>
7374

7475
{=# isAuthEnabled =}
75-
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & UserInContext>
76+
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser}>
7677

77-
export type UserInContext = {
78-
// TODO: This type must match the logic in core/auth.js (if we remove the
79-
// password field from the object there, we must do the same here). Ideally,
80-
// these two things would live in the same place:
81-
// https://github.com/wasp-lang/wasp/issues/965
82-
user: Omit<{= userEntityName =}, 'password'>
83-
}
78+
// TODO: This type must match the logic in core/auth.js (if we remove the
79+
// password field from the object there, we must do the same here). Ideally,
80+
// these two things would live in the same place:
81+
// https://github.com/wasp-lang/wasp/issues/965
82+
export type SanitizedUser = Omit<{= userEntityName =}, 'password'>
8483
{=/ isAuthEnabled =}
85-
86-
// This is a helper type used exclusively for DX purposes. It's a No-op for the
87-
// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
88-
// type constructors) to make it more readable for the user.
89-
//
90-
// Check this SO answer for details: https://stackoverflow.com/a/57683652
91-
type Expand<T extends object> = T extends infer O ? { [K in keyof O]: O[K] } : never

waspc/data/Generator/templates/server/src/actions/_action.js renamed to waspc/data/Generator/templates/server/src/actions/_action.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import prisma from '../dbClient.js'
77
consider in the future if it is worth removing this duplication. =}
88

99
export default async function (args, context) {
10-
return {= jsFn.importIdentifier =}(args, {
10+
return {= jsFn.importIdentifier =}(args as never, {
1111
...context,
1212
entities: {
1313
{=# entities =}
@@ -16,3 +16,5 @@ export default async function (args, context) {
1616
},
1717
})
1818
}
19+
20+
export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =}

waspc/data/Generator/templates/server/src/queries/_query.js renamed to waspc/data/Generator/templates/server/src/queries/_query.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import prisma from '../dbClient.js'
77
consider in the future if it is worth removing this duplication. =}
88

99
export default async function (args, context) {
10-
return {= jsFn.importIdentifier =}(args, {
10+
return {= jsFn.importIdentifier =}(args as never, {
1111
...context,
1212
entities: {
1313
{=# entities =}
@@ -16,3 +16,5 @@ export default async function (args, context) {
1616
},
1717
})
1818
}
19+
20+
export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =}

0 commit comments

Comments
 (0)