mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-10-26 17:10:02 +03:00
Automatic CRUD (#1197)
This commit is contained in:
parent
89dbb49160
commit
3faee611ac
99
waspc/data/Generator/templates/react-app/src/crud/_crud.ts
Normal file
99
waspc/data/Generator/templates/react-app/src/crud/_crud.ts
Normal file
@ -0,0 +1,99 @@
|
||||
{{={= =}=}}
|
||||
import { createAction } from "../actions/core";
|
||||
import { useAction } from "../actions";
|
||||
import { createQuery } from "../queries/core";
|
||||
import { useQuery } from "../queries";
|
||||
import {
|
||||
{=# operations.Get =}
|
||||
GetQueryResolved,
|
||||
{=/ operations.Get =}
|
||||
{=# operations.GetAll =}
|
||||
GetAllQueryResolved,
|
||||
{=/ operations.GetAll =}
|
||||
{=# operations.Create =}
|
||||
CreateActionResolved,
|
||||
{=/ operations.Create =}
|
||||
{=# operations.Update =}
|
||||
UpdateActionResolved,
|
||||
{=/ operations.Update =}
|
||||
{=# operations.Delete =}
|
||||
DeleteActionResolved,
|
||||
{=/ operations.Delete =}
|
||||
} from '../../../server/src/crud/{= name =}'
|
||||
|
||||
function createCrud() {
|
||||
{=# operations.Get =}
|
||||
const crudGetQuery = createQuery<GetQueryResolved>(
|
||||
'{= fullPath =}',
|
||||
{=& entitiesArray =}
|
||||
)
|
||||
{=/ operations.Get =}
|
||||
{=# operations.GetAll =}
|
||||
const crudGetAllQuery = createQuery<GetAllQueryResolved>(
|
||||
'{= fullPath =}',
|
||||
{=& entitiesArray =}
|
||||
)
|
||||
{=/ operations.GetAll =}
|
||||
{=# operations.Create =}
|
||||
const crudCreateAction = createAction<CreateActionResolved>(
|
||||
'{= fullPath =}',
|
||||
{=& entitiesArray =}
|
||||
)
|
||||
{=/ operations.Create =}
|
||||
{=# operations.Update =}
|
||||
const crudUpdateAction = createAction<UpdateActionResolved>(
|
||||
'{= fullPath =}',
|
||||
{=& entitiesArray =}
|
||||
)
|
||||
{=/ operations.Update =}
|
||||
{=# operations.Delete =}
|
||||
const crudDeleteAction = createAction<DeleteActionResolved>(
|
||||
'{= fullPath =}',
|
||||
{=& entitiesArray =}
|
||||
)
|
||||
{=/ operations.Delete =}
|
||||
return {
|
||||
{=# operations.Get =}
|
||||
get: {
|
||||
query: crudGetQuery,
|
||||
useQuery(args: Parameters<GetQueryResolved>[0]) {
|
||||
return useQuery(crudGetQuery, args);
|
||||
}
|
||||
},
|
||||
{=/ operations.Get =}
|
||||
{=# operations.GetAll =}
|
||||
getAll: {
|
||||
query: crudGetAllQuery,
|
||||
useQuery() {
|
||||
return useQuery(crudGetAllQuery);
|
||||
}
|
||||
},
|
||||
{=/ operations.GetAll =}
|
||||
{=# operations.Create =}
|
||||
create: {
|
||||
action: crudCreateAction,
|
||||
useAction() {
|
||||
return useAction(crudCreateAction);
|
||||
}
|
||||
},
|
||||
{=/ operations.Create =}
|
||||
{=# operations.Update =}
|
||||
update: {
|
||||
action: crudUpdateAction,
|
||||
useAction() {
|
||||
return useAction(crudUpdateAction);
|
||||
}
|
||||
},
|
||||
{=/ operations.Update =}
|
||||
{=# operations.Delete =}
|
||||
delete: {
|
||||
action: crudDeleteAction,
|
||||
useAction() {
|
||||
return useAction(crudDeleteAction);
|
||||
}
|
||||
},
|
||||
{=/ operations.Delete =}
|
||||
}
|
||||
}
|
||||
|
||||
export const {= name =} = createCrud();
|
218
waspc/data/Generator/templates/server/src/crud/_operations.ts
Normal file
218
waspc/data/Generator/templates/server/src/crud/_operations.ts
Normal file
@ -0,0 +1,218 @@
|
||||
{{={= =}=}}
|
||||
import prisma from "../dbClient.js";
|
||||
|
||||
import type {
|
||||
{=# isAuthEnabled =}
|
||||
AuthenticatedAction,
|
||||
AuthenticatedQuery,
|
||||
{=/ isAuthEnabled =}
|
||||
{=^ isAuthEnabled =}
|
||||
Action,
|
||||
Query,
|
||||
{=/ isAuthEnabled =}
|
||||
_{= crud.entityUpper =},
|
||||
} from "../_types";
|
||||
import type {
|
||||
Prisma,
|
||||
} from "@prisma/client";
|
||||
import type {
|
||||
{= crud.entityUpper =},
|
||||
} from "../entities";
|
||||
{=# isAuthEnabled =}
|
||||
import { throwInvalidCredentialsError } from "../core/auth.js";
|
||||
{=/ isAuthEnabled =}
|
||||
{=# overrides.GetAll.isDefined =}
|
||||
{=& overrides.GetAll.importStatement =}
|
||||
{=/ overrides.GetAll.isDefined =}
|
||||
{=# overrides.Get.isDefined =}
|
||||
{=& overrides.Get.importStatement =}
|
||||
{=/ overrides.Get.isDefined =}
|
||||
{=# overrides.Create.isDefined =}
|
||||
{=& overrides.Create.importStatement =}
|
||||
{=/ overrides.Create.isDefined =}
|
||||
{=# overrides.Update.isDefined =}
|
||||
{=& overrides.Update.importStatement =}
|
||||
{=/ overrides.Update.isDefined =}
|
||||
{=# overrides.Delete.isDefined =}
|
||||
{=& overrides.Delete.importStatement =}
|
||||
{=/ overrides.Delete.isDefined =}
|
||||
|
||||
type _WaspEntityTagged = _{= crud.entityUpper =}
|
||||
type _WaspEntity = {= crud.entityUpper =}
|
||||
const entities = {
|
||||
{= crud.entityUpper =}: prisma.{= crud.entityLower =},
|
||||
}
|
||||
|
||||
{=!
|
||||
// Let's explain this template on the GetAll operation example
|
||||
=}
|
||||
{=# crud.operations.GetAll =}
|
||||
// Get All query
|
||||
{=!
|
||||
// 1. We define the type for the operation using "queryType" template variable which is either
|
||||
// AuthenticatedQuery or Query (it depends on whether auth is enabled or not).
|
||||
=}
|
||||
export type GetAllQuery<Input, Output> = {= queryType =}<[_WaspEntityTagged], Input, Output>
|
||||
{=!
|
||||
// 2. Then, we either use the default implementation of the operation...
|
||||
=}
|
||||
{=^ overrides.GetAll.isDefined =}
|
||||
type GetAllInput = {}
|
||||
type GetAllOutput = _WaspEntity[]
|
||||
const _waspGetAllQuery: GetAllQuery<GetAllInput, GetAllOutput> = ((args, context) => {
|
||||
{=^ crud.operations.GetAll.isPublic =}
|
||||
throwIfNotAuthenticated(context)
|
||||
{=/ crud.operations.GetAll.isPublic =}
|
||||
return context.entities.{= crud.entityUpper =}.findMany();
|
||||
});
|
||||
{=/ overrides.GetAll.isDefined =}
|
||||
{=!
|
||||
// ... or the one defined in the overrides by the user. We use the "importIdentifier" property to
|
||||
// reference the function from the overrides.
|
||||
=}
|
||||
{=# overrides.GetAll.isDefined =}
|
||||
const _waspGetAllQuery = {= overrides.GetAll.importIdentifier =}
|
||||
{=/ overrides.GetAll.isDefined =}
|
||||
|
||||
{=!
|
||||
// 3. We then define the final type for the operation, which is the type of the function we defined in the previous step.
|
||||
// It will pick up either the default implementation or the one from the overrides.
|
||||
=}
|
||||
export type GetAllQueryResolved = typeof _waspGetAllQuery
|
||||
|
||||
{=!
|
||||
// 4. We define a function that is used as the Express route handler
|
||||
=}
|
||||
export async function getAllFn(args, context) {
|
||||
return (_waspGetAllQuery as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
{=/ crud.operations.GetAll =}
|
||||
{=!
|
||||
// That's it! It is similar for all other operations.
|
||||
=}
|
||||
|
||||
{=# crud.operations.Get =}
|
||||
// Get query
|
||||
export type GetQuery<Input, Output> = {= queryType =}<[_WaspEntityTagged], Input, Output>
|
||||
{=^ overrides.Get.isDefined =}
|
||||
type GetInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
|
||||
type GetOutput = _WaspEntity | null
|
||||
const _waspGetQuery: GetQuery<GetInput, GetOutput> = ((args, context) => {
|
||||
{=^ crud.operations.Get.isPublic =}
|
||||
throwIfNotAuthenticated(context)
|
||||
{=/ crud.operations.Get.isPublic =}
|
||||
return context.entities.{= crud.entityUpper =}.findUnique({ where: { id: args.id } });
|
||||
});
|
||||
{=/ overrides.Get.isDefined =}
|
||||
{=# overrides.Get.isDefined =}
|
||||
const _waspGetQuery = {= overrides.Get.importIdentifier =}
|
||||
{=/ overrides.Get.isDefined =}
|
||||
export type GetQueryResolved = typeof _waspGetQuery
|
||||
|
||||
export async function getFn(args, context) {
|
||||
return (_waspGetQuery as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
{=/ crud.operations.Get =}
|
||||
|
||||
{=# crud.operations.Create =}
|
||||
// Create action
|
||||
export type CreateAction<Input, Output>= {= actionType =}<[_WaspEntityTagged], Input, Output>
|
||||
{=^ overrides.Create.isDefined =}
|
||||
type CreateInput = Prisma.{= crud.entityUpper =}CreateInput
|
||||
type CreateOutput = _WaspEntity
|
||||
const _waspCreateAction: CreateAction<CreateInput, CreateOutput> = ((args, context) => {
|
||||
{=^ crud.operations.Create.isPublic =}
|
||||
throwIfNotAuthenticated(context)
|
||||
{=/ crud.operations.Create.isPublic =}
|
||||
return context.entities.{= crud.entityUpper =}.create({ data: args });
|
||||
});
|
||||
{=/ overrides.Create.isDefined =}
|
||||
{=# overrides.Create.isDefined =}
|
||||
const _waspCreateAction = {= overrides.Create.importIdentifier =}
|
||||
{=/ overrides.Create.isDefined =}
|
||||
|
||||
export type CreateActionResolved = typeof _waspCreateAction
|
||||
|
||||
export async function createFn(args, context) {
|
||||
return (_waspCreateAction as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
{=/ crud.operations.Create =}
|
||||
|
||||
{=# crud.operations.Update =}
|
||||
// Update action
|
||||
export type UpdateAction<Input, Output> = {= actionType =}<[_WaspEntityTagged], Input, Output>
|
||||
{=^ overrides.Update.isDefined =}
|
||||
type UpdateInput = Prisma.{= crud.entityUpper =}UpdateInput & Prisma.{= crud.entityUpper =}WhereUniqueInput
|
||||
type UpdateOutput = _WaspEntity
|
||||
const _waspUpdateAction: UpdateAction<UpdateInput, UpdateOutput> = ((args, context) => {
|
||||
{=^ crud.operations.Update.isPublic =}
|
||||
throwIfNotAuthenticated(context)
|
||||
{=/ crud.operations.Update.isPublic =}
|
||||
const { {= crud.idFieldName =}: idFieldValue, ...rest } = args
|
||||
return context.entities.{= crud.entityUpper =}.update({
|
||||
where: { {= crud.idFieldName =}: idFieldValue },
|
||||
data: rest,
|
||||
});
|
||||
});
|
||||
{=/ overrides.Update.isDefined =}
|
||||
{=# overrides.Update.isDefined =}
|
||||
const _waspUpdateAction = {= overrides.Update.importIdentifier =}
|
||||
{=/ overrides.Update.isDefined =}
|
||||
|
||||
export type UpdateActionResolved = typeof _waspUpdateAction
|
||||
|
||||
export async function updateFn(args, context) {
|
||||
return (_waspUpdateAction as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
{=/ crud.operations.Update =}
|
||||
|
||||
{=# crud.operations.Delete =}
|
||||
// Delete action
|
||||
export type DeleteAction<Input, Output> = {= actionType =}<[_WaspEntityTagged], Input, Output>
|
||||
{=^ overrides.Delete.isDefined =}
|
||||
type DeleteInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
|
||||
type DeleteOutput = _WaspEntity
|
||||
const _waspDeleteAction: DeleteAction<DeleteInput, DeleteOutput> = ((args, context) => {
|
||||
{=^ crud.operations.Delete.isPublic =}
|
||||
throwIfNotAuthenticated(context)
|
||||
{=/ crud.operations.Delete.isPublic =}
|
||||
const { {= crud.idFieldName =}: idFieldValue } = args
|
||||
return context.entities.{= crud.entityUpper =}.delete({ where: { {= crud.idFieldName =}: idFieldValue } });
|
||||
});
|
||||
{=/ overrides.Delete.isDefined =}
|
||||
{=# overrides.Delete.isDefined =}
|
||||
const _waspDeleteAction = {= overrides.Delete.importIdentifier =}
|
||||
{=/ overrides.Delete.isDefined =}
|
||||
|
||||
export type DeleteActionResolved = typeof _waspDeleteAction
|
||||
|
||||
export async function deleteFn(args, context) {
|
||||
return (_waspDeleteAction as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
{=/ crud.operations.Delete =}
|
||||
|
||||
function throwIfNotAuthenticated (context) {
|
||||
{=# isAuthEnabled =}
|
||||
if (!context.user) {
|
||||
throwInvalidCredentialsError()
|
||||
}
|
||||
{=/ isAuthEnabled =}
|
||||
{=^ isAuthEnabled =}
|
||||
// Auth is not enabled
|
||||
{=/ isAuthEnabled =}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
{{={= =}=}}
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
{=# isAuthEnabled =}
|
||||
user: req.user
|
||||
{=/ isAuthEnabled =}
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
{{={= =}=}}
|
||||
import express from 'express'
|
||||
import * as crud from '../../crud/{= crud.name =}.js'
|
||||
import { createAction, createQuery } from '../../middleware/operations.js'
|
||||
{=# isAuthEnabled =}
|
||||
import auth from '../../core/auth.js'
|
||||
{=/ isAuthEnabled =}
|
||||
|
||||
const _waspRouter = express.Router()
|
||||
|
||||
{=# isAuthEnabled =}
|
||||
_waspRouter.use(auth)
|
||||
{=/ isAuthEnabled =}
|
||||
|
||||
{=# crud.operations.Get =}
|
||||
_waspRouter.post(
|
||||
'/{= route =}',
|
||||
createQuery(crud.getFn),
|
||||
)
|
||||
{=/ crud.operations.Get =}
|
||||
{=# crud.operations.GetAll =}
|
||||
_waspRouter.post(
|
||||
'/{= route =}',
|
||||
createQuery(crud.getAllFn),
|
||||
)
|
||||
{=/ crud.operations.GetAll =}
|
||||
{=# crud.operations.Create =}
|
||||
_waspRouter.post(
|
||||
'/{= route =}',
|
||||
createAction(crud.createFn),
|
||||
)
|
||||
{=/ crud.operations.Create =}
|
||||
{=# crud.operations.Update =}
|
||||
_waspRouter.post(
|
||||
'/{= route =}',
|
||||
createAction(crud.updateFn),
|
||||
)
|
||||
{=/ crud.operations.Update =}
|
||||
{=# crud.operations.Delete =}
|
||||
_waspRouter.post(
|
||||
'/{= route =}',
|
||||
createAction(crud.deleteFn),
|
||||
)
|
||||
{=/ crud.operations.Delete =}
|
||||
|
||||
export const {= crud.name =} = _waspRouter
|
@ -0,0 +1,12 @@
|
||||
{{={= =}=}}
|
||||
import express from 'express'
|
||||
|
||||
{=# crudRouters =}
|
||||
{=& importStatement =}
|
||||
{=/ crudRouters =}
|
||||
|
||||
export const rootCrudRouter = express.Router()
|
||||
|
||||
{=# crudRouters =}
|
||||
rootCrudRouter.use('/{= route =}', {= importIdentifier =})
|
||||
{=/ crudRouters =}
|
@ -8,6 +8,9 @@ import auth from './auth/index.js'
|
||||
{=# areThereAnyCustomApiRoutes =}
|
||||
import apis from './apis/index.js'
|
||||
{=/ areThereAnyCustomApiRoutes =}
|
||||
{=# areThereAnyCrudRoutes =}
|
||||
import { rootCrudRouter } from './crud/index.js'
|
||||
{=/ areThereAnyCrudRoutes =}
|
||||
|
||||
|
||||
const router = express.Router()
|
||||
@ -21,6 +24,9 @@ router.get('/', middleware, function (_req, res, _next) {
|
||||
router.use('/auth', middleware, auth)
|
||||
{=/ isAuthEnabled =}
|
||||
router.use('/{= operationsRouteInRootRouter =}', middleware, operations)
|
||||
{=# areThereAnyCrudRoutes =}
|
||||
router.use('/', middleware, rootCrudRouter)
|
||||
{=/ areThereAnyCrudRoutes =}
|
||||
{=# areThereAnyCustomApiRoutes =}
|
||||
// NOTE: Keep user-defined api routes last so they cannot override our routes.
|
||||
// Additionally, do not add middleware to these routes here. Instead, we add
|
||||
|
@ -1,22 +1,5 @@
|
||||
{{={= =}=}}
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../../utils.js'
|
||||
{=& operationImportStmt =}
|
||||
|
||||
export default handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
|
||||
const context = {
|
||||
{=# userEntityLower =}
|
||||
user: req.user
|
||||
{=/ userEntityLower =}
|
||||
}
|
||||
|
||||
const result = await {= operationName =}(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
import { createAction } from '../../middleware/operations.js'
|
||||
{=& operation.importStatement =}
|
||||
|
||||
export default createAction({= operation.importIdentifier =})
|
||||
|
@ -1,28 +1,5 @@
|
||||
{{={= =}=}}
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../../utils.js'
|
||||
{=& operationImportStmt =}
|
||||
import { createQuery } from '../../middleware/operations.js'
|
||||
{=& operation.importStatement =}
|
||||
|
||||
export default handleRejection(async (req, res) => {
|
||||
{=! TODO: When generating express route for query, generated code would be most human-like if we
|
||||
generated GET route that uses query arguments.
|
||||
However, for that, we need to know the types of the arguments so we can cast/parse them.
|
||||
Also, there is limit on URI length, which could be problem if users want to send some bigger
|
||||
JSON objects or smth.
|
||||
So for now we are just going with POST that has JSON in the body -> generated code is not
|
||||
as human-like as it should be though. =}
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
|
||||
const context = {
|
||||
{=# userEntityLower =}
|
||||
user: req.user
|
||||
{=/ userEntityLower =}
|
||||
}
|
||||
|
||||
const result = await {= operationName =}(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
export default createQuery({= operation.importIdentifier =})
|
||||
|
@ -33,6 +33,7 @@ waspComplexTest = do
|
||||
<++> addQuery
|
||||
<++> addApi
|
||||
<++> addApiNamespace
|
||||
<++> addCrud
|
||||
<++> sequence
|
||||
[ waspCliCompile
|
||||
]
|
||||
@ -313,6 +314,33 @@ addEmailSender = do
|
||||
" },"
|
||||
]
|
||||
|
||||
addCrud :: ShellCommandBuilder [ShellCommand]
|
||||
addCrud = do
|
||||
sequence
|
||||
[ appendToWaspFile taskEntityDecl,
|
||||
appendToWaspFile crudDecl
|
||||
]
|
||||
where
|
||||
taskEntityDecl =
|
||||
unlines
|
||||
[ "entity Task {=psl",
|
||||
" id Int @id @default(autoincrement())",
|
||||
" description String",
|
||||
" isDone Boolean @default(false)",
|
||||
"psl=}"
|
||||
]
|
||||
crudDecl =
|
||||
unlines
|
||||
[ "crud tasks {",
|
||||
" entity: Task,",
|
||||
" operations: {",
|
||||
" get: {},",
|
||||
" getAll: {},",
|
||||
" create: {},",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
|
||||
insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand
|
||||
insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion
|
||||
where
|
||||
|
@ -28,6 +28,7 @@ waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/simpleJob.js
|
||||
waspBuild/.wasp/build/server/src/middleware/globalMiddleware.ts
|
||||
waspBuild/.wasp/build/server/src/middleware/index.ts
|
||||
waspBuild/.wasp/build/server/src/middleware/operations.ts
|
||||
waspBuild/.wasp/build/server/src/queries/types.ts
|
||||
waspBuild/.wasp/build/server/src/routes/index.js
|
||||
waspBuild/.wasp/build/server/src/routes/operations/index.js
|
||||
|
@ -202,6 +202,13 @@
|
||||
],
|
||||
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/middleware/operations.ts"
|
||||
],
|
||||
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
|
@ -0,0 +1,24 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
@ -29,6 +29,7 @@ waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspCompile/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspCompile/.wasp/out/server/src/middleware/index.ts
|
||||
waspCompile/.wasp/out/server/src/middleware/operations.ts
|
||||
waspCompile/.wasp/out/server/src/queries/types.ts
|
||||
waspCompile/.wasp/out/server/src/routes/index.js
|
||||
waspCompile/.wasp/out/server/src/routes/operations/index.js
|
||||
|
@ -209,6 +209,13 @@
|
||||
],
|
||||
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/middleware/operations.ts"
|
||||
],
|
||||
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
|
@ -0,0 +1,24 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
@ -35,6 +35,7 @@ waspComplexTest/.wasp/out/server/src/core/HttpError.js
|
||||
waspComplexTest/.wasp/out/server/src/core/auth.js
|
||||
waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js
|
||||
waspComplexTest/.wasp/out/server/src/core/auth/validators.ts
|
||||
waspComplexTest/.wasp/out/server/src/crud/tasks.ts
|
||||
waspComplexTest/.wasp/out/server/src/dbClient.ts
|
||||
waspComplexTest/.wasp/out/server/src/dbSeed/types.ts
|
||||
waspComplexTest/.wasp/out/server/src/email/core/helpers.ts
|
||||
@ -59,11 +60,14 @@ waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspComplexTest/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspComplexTest/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspComplexTest/.wasp/out/server/src/middleware/index.ts
|
||||
waspComplexTest/.wasp/out/server/src/middleware/operations.ts
|
||||
waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts
|
||||
waspComplexTest/.wasp/out/server/src/queries/types.ts
|
||||
waspComplexTest/.wasp/out/server/src/routes/apis/index.ts
|
||||
waspComplexTest/.wasp/out/server/src/routes/auth/index.js
|
||||
waspComplexTest/.wasp/out/server/src/routes/auth/me.js
|
||||
waspComplexTest/.wasp/out/server/src/routes/crud/index.ts
|
||||
waspComplexTest/.wasp/out/server/src/routes/crud/tasks.ts
|
||||
waspComplexTest/.wasp/out/server/src/routes/index.js
|
||||
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js
|
||||
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js
|
||||
@ -106,6 +110,7 @@ waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx
|
||||
waspComplexTest/.wasp/out/web-app/src/auth/types.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/config.js
|
||||
waspComplexTest/.wasp/out/web-app/src/crud/tasks.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/entities/index.ts
|
||||
waspComplexTest/.wasp/out/web-app/src/ext-src/App.jsx
|
||||
waspComplexTest/.wasp/out/web-app/src/ext-src/Main.css
|
||||
|
@ -18,7 +18,7 @@
|
||||
"file",
|
||||
"db/schema.prisma"
|
||||
],
|
||||
"ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0"
|
||||
"cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -88,7 +88,7 @@
|
||||
"file",
|
||||
"server/src/_types/index.ts"
|
||||
],
|
||||
"d959101b32583258a92f136a8ac169f977a61822dfd51b8dfe205a7f0ec73a57"
|
||||
"9e8542f1b36712bbf3d633400377fed4da6fd15e69394ae521935c33cbdbbe36"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -102,7 +102,7 @@
|
||||
"file",
|
||||
"server/src/_types/taggedEntities.ts"
|
||||
],
|
||||
"ac5570e21d89fbb8418a2fc26cbbec7b189c13b4bfab5f0b006d356411b8f5a2"
|
||||
"1d95c918d216f7cc79f1a861c29eb251794e1f1ea2ce53452a81928429a21430"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -230,6 +230,13 @@
|
||||
],
|
||||
"d7cfe22168d66e0d346c2aec33573c0346c9f0f5c854d3f204bed5b4c315da87"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/crud/tasks.ts"
|
||||
],
|
||||
"4b27b90bd0f46b570ad53cb121259e32661829ee081a1990ba65ab16921f16ae"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
@ -291,7 +298,7 @@
|
||||
"file",
|
||||
"server/src/entities/index.ts"
|
||||
],
|
||||
"3dc318116ab03fc779f408442a2608011ea1f3d8f9a28109f27041b7d22ef3d1"
|
||||
"783fbc250d0628073328625ff299f120ac8bef45232c10b7d7b5897d42b788c1"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -398,6 +405,13 @@
|
||||
],
|
||||
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/middleware/operations.ts"
|
||||
],
|
||||
"64eeed927f46f6d6eba143023f25fb9ac4cd81d6b68c9a7067306ad28a3eda92"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
@ -433,26 +447,40 @@
|
||||
],
|
||||
"705f77d8970a8367981c9a89601c6d5b12e998c23970ae1735b376dd0826ef10"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/routes/crud/index.ts"
|
||||
],
|
||||
"b66ff949dbdaa892a354f3a60dc59ce229323ef55fad38239f8b335ec1e71b8d"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/routes/crud/tasks.ts"
|
||||
],
|
||||
"cb5a53c503c946083cc46b2500a4eab49b3bfa867d3bcc1cae50355c0796369c"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/routes/index.js"
|
||||
],
|
||||
"57c6074cfb790ea019efbab199e860e70248a3e758419312939ba63c7a84a42c"
|
||||
"b589a916a4ded52b75af42e299a506cbeb4d56ce2e87bec29f211c3e33865049"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/routes/operations/MySpecialAction.js"
|
||||
],
|
||||
"19eea333d3f3f559b93b49f34a3a6f474d86b4d8b83c70f4bb456ef44e361eef"
|
||||
"6bda2fb808fcc69fedb8f69aa08ceea94c668abec42643e15f59a3c161854bbb"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/routes/operations/MySpecialQuery.js"
|
||||
],
|
||||
"f8a7ce6ab0c320f24e76d12c5ba01891d8c408c80f07bbf8a6471fafb29abc91"
|
||||
"af52108997cac2f416819822a9b7d2615121270a1c5886b5df7e03ae5cf1fc09"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -734,12 +762,19 @@
|
||||
],
|
||||
"b06175129dd8be8ca6c307fafa02646d47233c4e26fdfeea6d7802d02e9513f3"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/crud/tasks.ts"
|
||||
],
|
||||
"2f4ca60123e3fe999dbe4814182b7d09d0039db120e5c260ccc824806561b5e7"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"web-app/src/entities/index.ts"
|
||||
],
|
||||
"642ab14a4d5b92152ba458b210502adea70fa7a6d1d6af35108a71236a33e89b"
|
||||
"cfb9f8237f80aea777840621702d03803172b005b3aaee2fc0be6c9f1b1c8414"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -26,3 +26,9 @@ model SocialLogin {
|
||||
@@unique([provider, providerId, userId])
|
||||
|
||||
}
|
||||
model Task {
|
||||
id Int @id @default(autoincrement())
|
||||
description String
|
||||
isDone Boolean @default(false)
|
||||
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0
|
||||
cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1
|
@ -64,6 +64,7 @@ type EntityMap<Entities extends _Entity[]> = {
|
||||
type PrismaDelegate = {
|
||||
"User": typeof prisma.user,
|
||||
"SocialLogin": typeof prisma.socialLogin,
|
||||
"Task": typeof prisma.task,
|
||||
}
|
||||
|
||||
type Context<Entities extends _Entity[]> = Expand<{
|
||||
|
@ -8,14 +8,17 @@ import {
|
||||
type EntityName,
|
||||
type User,
|
||||
type SocialLogin,
|
||||
type Task,
|
||||
} from '../entities'
|
||||
|
||||
export type _User = WithName<User, "User">
|
||||
export type _SocialLogin = WithName<SocialLogin, "SocialLogin">
|
||||
export type _Task = WithName<Task, "Task">
|
||||
|
||||
export type _Entity =
|
||||
| _User
|
||||
| _SocialLogin
|
||||
| _Task
|
||||
| never
|
||||
|
||||
type WithName<E extends Entity, Name extends EntityName> =
|
||||
|
@ -0,0 +1,81 @@
|
||||
import prisma from "../dbClient.js";
|
||||
|
||||
import type {
|
||||
AuthenticatedAction,
|
||||
AuthenticatedQuery,
|
||||
_Task,
|
||||
} from "../_types";
|
||||
import type {
|
||||
Prisma,
|
||||
} from "@prisma/client";
|
||||
import type {
|
||||
Task,
|
||||
} from "../entities";
|
||||
import { throwInvalidCredentialsError } from "../core/auth.js";
|
||||
|
||||
type _WaspEntityTagged = _Task
|
||||
type _WaspEntity = Task
|
||||
const entities = {
|
||||
Task: prisma.task,
|
||||
}
|
||||
|
||||
// Get All query
|
||||
export type GetAllQuery<Input, Output> = AuthenticatedQuery<[_WaspEntityTagged], Input, Output>
|
||||
type GetAllInput = {}
|
||||
type GetAllOutput = _WaspEntity[]
|
||||
const _waspGetAllQuery: GetAllQuery<GetAllInput, GetAllOutput> = ((args, context) => {
|
||||
throwIfNotAuthenticated(context)
|
||||
return context.entities.Task.findMany();
|
||||
});
|
||||
|
||||
export type GetAllQueryResolved = typeof _waspGetAllQuery
|
||||
|
||||
export async function getAllFn(args, context) {
|
||||
return (_waspGetAllQuery as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
|
||||
// Get query
|
||||
export type GetQuery<Input, Output> = AuthenticatedQuery<[_WaspEntityTagged], Input, Output>
|
||||
type GetInput = Prisma.TaskWhereUniqueInput
|
||||
type GetOutput = _WaspEntity | null
|
||||
const _waspGetQuery: GetQuery<GetInput, GetOutput> = ((args, context) => {
|
||||
throwIfNotAuthenticated(context)
|
||||
return context.entities.Task.findUnique({ where: { id: args.id } });
|
||||
});
|
||||
export type GetQueryResolved = typeof _waspGetQuery
|
||||
|
||||
export async function getFn(args, context) {
|
||||
return (_waspGetQuery as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
|
||||
// Create action
|
||||
export type CreateAction<Input, Output>= AuthenticatedAction<[_WaspEntityTagged], Input, Output>
|
||||
type CreateInput = Prisma.TaskCreateInput
|
||||
type CreateOutput = _WaspEntity
|
||||
const _waspCreateAction: CreateAction<CreateInput, CreateOutput> = ((args, context) => {
|
||||
throwIfNotAuthenticated(context)
|
||||
return context.entities.Task.create({ data: args });
|
||||
});
|
||||
|
||||
export type CreateActionResolved = typeof _waspCreateAction
|
||||
|
||||
export async function createFn(args, context) {
|
||||
return (_waspCreateAction as any)(args, {
|
||||
...context,
|
||||
entities,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function throwIfNotAuthenticated (context) {
|
||||
if (!context.user) {
|
||||
throwInvalidCredentialsError()
|
||||
}
|
||||
}
|
@ -1,19 +1,23 @@
|
||||
import {
|
||||
type User,
|
||||
type SocialLogin,
|
||||
type Task,
|
||||
} from "@prisma/client"
|
||||
|
||||
export {
|
||||
type User,
|
||||
type SocialLogin,
|
||||
type Task,
|
||||
} from "@prisma/client"
|
||||
|
||||
export type Entity =
|
||||
| User
|
||||
| SocialLogin
|
||||
| Task
|
||||
| never
|
||||
|
||||
export type EntityName =
|
||||
| "User"
|
||||
| "SocialLogin"
|
||||
| "Task"
|
||||
| never
|
||||
|
@ -0,0 +1,25 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
user: req.user
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import express from 'express'
|
||||
|
||||
import { tasks } from './tasks.js'
|
||||
|
||||
export const rootCrudRouter = express.Router()
|
||||
|
||||
rootCrudRouter.use('/tasks', tasks)
|
@ -0,0 +1,23 @@
|
||||
import express from 'express'
|
||||
import * as crud from '../../crud/tasks.js'
|
||||
import { createAction, createQuery } from '../../middleware/operations.js'
|
||||
import auth from '../../core/auth.js'
|
||||
|
||||
const _waspRouter = express.Router()
|
||||
|
||||
_waspRouter.use(auth)
|
||||
|
||||
_waspRouter.post(
|
||||
'/get',
|
||||
createQuery(crud.getFn),
|
||||
)
|
||||
_waspRouter.post(
|
||||
'/get-all',
|
||||
createQuery(crud.getAllFn),
|
||||
)
|
||||
_waspRouter.post(
|
||||
'/create',
|
||||
createAction(crud.createFn),
|
||||
)
|
||||
|
||||
export const tasks = _waspRouter
|
@ -3,6 +3,7 @@ import operations from './operations/index.js'
|
||||
import { globalMiddlewareConfigForExpress } from '../middleware/index.js'
|
||||
import auth from './auth/index.js'
|
||||
import apis from './apis/index.js'
|
||||
import { rootCrudRouter } from './crud/index.js'
|
||||
|
||||
|
||||
const router = express.Router()
|
||||
@ -14,6 +15,7 @@ router.get('/', middleware, function (_req, res, _next) {
|
||||
|
||||
router.use('/auth', middleware, auth)
|
||||
router.use('/operations', middleware, operations)
|
||||
router.use('/', middleware, rootCrudRouter)
|
||||
// NOTE: Keep user-defined api routes last so they cannot override our routes.
|
||||
// Additionally, do not add middleware to these routes here. Instead, we add
|
||||
// it later to allow for middleware customization.
|
||||
|
@ -1,19 +1,4 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../../utils.js'
|
||||
import { createAction } from '../../middleware/operations.js'
|
||||
import MySpecialAction from '../../actions/MySpecialAction.js'
|
||||
|
||||
export default handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
|
||||
const context = {
|
||||
user: req.user
|
||||
}
|
||||
|
||||
const result = await MySpecialAction(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
|
||||
export default createAction(MySpecialAction)
|
||||
|
@ -1,18 +1,4 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../../utils.js'
|
||||
import { createQuery } from '../../middleware/operations.js'
|
||||
import MySpecialQuery from '../../queries/MySpecialQuery.js'
|
||||
|
||||
export default handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
|
||||
const context = {
|
||||
user: req.user
|
||||
}
|
||||
|
||||
const result = await MySpecialQuery(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
export default createQuery(MySpecialQuery)
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { createAction } from "../actions/core";
|
||||
import { useAction } from "../actions";
|
||||
import { createQuery } from "../queries/core";
|
||||
import { useQuery } from "../queries";
|
||||
import {
|
||||
GetQueryResolved,
|
||||
GetAllQueryResolved,
|
||||
CreateActionResolved,
|
||||
} from '../../../server/src/crud/tasks'
|
||||
|
||||
function createCrud() {
|
||||
const crudGetQuery = createQuery<GetQueryResolved>(
|
||||
'tasks/get',
|
||||
['Task']
|
||||
)
|
||||
const crudGetAllQuery = createQuery<GetAllQueryResolved>(
|
||||
'tasks/get-all',
|
||||
['Task']
|
||||
)
|
||||
const crudCreateAction = createAction<CreateActionResolved>(
|
||||
'tasks/create',
|
||||
['Task']
|
||||
)
|
||||
return {
|
||||
get: {
|
||||
query: crudGetQuery,
|
||||
useQuery(args: Parameters<GetQueryResolved>[0]) {
|
||||
return useQuery(crudGetQuery, args);
|
||||
}
|
||||
},
|
||||
getAll: {
|
||||
query: crudGetAllQuery,
|
||||
useQuery() {
|
||||
return useQuery(crudGetAllQuery);
|
||||
}
|
||||
},
|
||||
create: {
|
||||
action: crudCreateAction,
|
||||
useAction() {
|
||||
return useAction(crudCreateAction);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const tasks = createCrud();
|
@ -1,14 +1,17 @@
|
||||
import {
|
||||
User,
|
||||
SocialLogin,
|
||||
Task,
|
||||
} from '@prisma/client'
|
||||
|
||||
export type {
|
||||
User,
|
||||
SocialLogin,
|
||||
Task,
|
||||
} from '@prisma/client'
|
||||
|
||||
export type Entity =
|
||||
| User
|
||||
| SocialLogin
|
||||
| Task
|
||||
| never
|
||||
|
@ -91,3 +91,18 @@ apiNamespace fooBarNamespace {
|
||||
path: "/bar"
|
||||
}
|
||||
|
||||
entity Task {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
description String
|
||||
isDone Boolean @default(false)
|
||||
psl=}
|
||||
|
||||
crud tasks {
|
||||
entity: Task,
|
||||
operations: {
|
||||
get: {},
|
||||
getAll: {},
|
||||
create: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,7 @@ waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspJob/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspJob/.wasp/out/server/src/middleware/index.ts
|
||||
waspJob/.wasp/out/server/src/middleware/operations.ts
|
||||
waspJob/.wasp/out/server/src/queries/types.ts
|
||||
waspJob/.wasp/out/server/src/routes/index.js
|
||||
waspJob/.wasp/out/server/src/routes/operations/index.js
|
||||
|
@ -223,6 +223,13 @@
|
||||
],
|
||||
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/middleware/operations.ts"
|
||||
],
|
||||
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
|
24
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/middleware/operations.ts
generated
Normal file
24
waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/middleware/operations.ts
generated
Normal file
@ -0,0 +1,24 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
@ -34,6 +34,7 @@ waspMigrate/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/simpleJob.js
|
||||
waspMigrate/.wasp/out/server/src/middleware/globalMiddleware.ts
|
||||
waspMigrate/.wasp/out/server/src/middleware/index.ts
|
||||
waspMigrate/.wasp/out/server/src/middleware/operations.ts
|
||||
waspMigrate/.wasp/out/server/src/queries/types.ts
|
||||
waspMigrate/.wasp/out/server/src/routes/index.js
|
||||
waspMigrate/.wasp/out/server/src/routes/operations/index.js
|
||||
|
@ -209,6 +209,13 @@
|
||||
],
|
||||
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/middleware/operations.ts"
|
||||
],
|
||||
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
|
@ -0,0 +1,24 @@
|
||||
import {
|
||||
deserialize as superjsonDeserialize,
|
||||
serialize as superjsonSerialize,
|
||||
} from 'superjson'
|
||||
import { handleRejection } from '../utils.js'
|
||||
|
||||
export function createOperation (handlerFn) {
|
||||
return handleRejection(async (req, res) => {
|
||||
const args = (req.body && superjsonDeserialize(req.body)) || {}
|
||||
const context = {
|
||||
}
|
||||
const result = await handlerFn(args, context)
|
||||
const serializedResult = superjsonSerialize(result)
|
||||
res.json(serializedResult)
|
||||
})
|
||||
}
|
||||
|
||||
export function createQuery(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
||||
|
||||
export function createAction(handlerFn) {
|
||||
return createOperation(handlerFn)
|
||||
}
|
3
waspc/examples/crud-testing/.gitignore
vendored
Normal file
3
waspc/examples/crud-testing/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/.wasp/
|
||||
/.env.server
|
||||
/.env.client
|
1
waspc/examples/crud-testing/.wasproot
Normal file
1
waspc/examples/crud-testing/.wasproot
Normal file
@ -0,0 +1 @@
|
||||
File marking the root of Wasp project.
|
69
waspc/examples/crud-testing/main.wasp
Normal file
69
waspc/examples/crud-testing/main.wasp
Normal file
@ -0,0 +1,69 @@
|
||||
app crudTesting {
|
||||
wasp: {
|
||||
version: "^0.10.4"
|
||||
},
|
||||
head: [
|
||||
"<link rel=\"stylesheet\" href=\"https://unpkg.com/mvp.css@1.12/mvp.css\">"
|
||||
],
|
||||
title: "crud-testing",
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
usernameAndPassword: {},
|
||||
},
|
||||
onAuthFailedRedirectTo: "/login",
|
||||
},
|
||||
}
|
||||
|
||||
route RootRoute { path: "/", to: MainPage }
|
||||
page MainPage {
|
||||
component: import Main from "@client/MainPage.tsx",
|
||||
authRequired: true,
|
||||
}
|
||||
|
||||
route LoginRoute { path: "/login", to: LoginPage }
|
||||
page LoginPage {
|
||||
component: import { LoginPage } from "@client/LoginPage.tsx",
|
||||
}
|
||||
|
||||
route SignupRoute { path: "/signup", to: SignupPage }
|
||||
page SignupPage {
|
||||
component: import { SignupPage } from "@client/SignupPage.tsx",
|
||||
}
|
||||
|
||||
route DetailRoute { path: "/:id", to: DetailPage }
|
||||
page DetailPage {
|
||||
component: import Main from "@client/DetailPage.tsx",
|
||||
authRequired: true,
|
||||
}
|
||||
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
tasks Task[]
|
||||
psl=}
|
||||
|
||||
// TODO: validate the fields
|
||||
// Maybe delegate to Prisma
|
||||
entity Task {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
psl=}
|
||||
|
||||
crud tasks {
|
||||
entity: Task,
|
||||
operations: {
|
||||
get: {},
|
||||
getAll: {
|
||||
overrideFn: import { getAllTasks } from "@server/tasks.js",
|
||||
},
|
||||
create: {
|
||||
overrideFn: import { createTask } from "@server/tasks.js",
|
||||
},
|
||||
update: {},
|
||||
delete: {},
|
||||
},
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
3
waspc/examples/crud-testing/src/.waspignore
Normal file
3
waspc/examples/crud-testing/src/.waspignore
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore editor tmp files
|
||||
**/*~
|
||||
**/#*#
|
36
waspc/examples/crud-testing/src/client/DetailPage.tsx
Normal file
36
waspc/examples/crud-testing/src/client/DetailPage.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import "./Main.css";
|
||||
|
||||
import React from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { tasks as tasksCrud } from "@wasp/crud/tasks";
|
||||
|
||||
const DetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: task, isLoading } = tasksCrud.get.useQuery({
|
||||
id: parseInt(id, 10),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<h1>Tasks master</h1>
|
||||
<div className="tasks">
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{task && (
|
||||
<div key={task.id} className="task">
|
||||
<>
|
||||
<div className="task__title">
|
||||
{JSON.stringify(task, null, 2)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/">Return</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailPage;
|
12
waspc/examples/crud-testing/src/client/LoginPage.tsx
Normal file
12
waspc/examples/crud-testing/src/client/LoginPage.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { LoginForm } from "@wasp/auth/forms/Login";
|
||||
|
||||
export const LoginPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<h1>Login</h1>
|
||||
<LoginForm />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
44
waspc/examples/crud-testing/src/client/Main.css
Normal file
44
waspc/examples/crud-testing/src/client/Main.css
Normal file
@ -0,0 +1,44 @@
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.task + .task {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
121
waspc/examples/crud-testing/src/client/MainPage.tsx
Normal file
121
waspc/examples/crud-testing/src/client/MainPage.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import "./Main.css";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { tasks as tasksCrud } from "@wasp/crud/tasks";
|
||||
import { User, Task } from "@wasp/entities";
|
||||
|
||||
const MainPage = ({ user }: { user: User }) => {
|
||||
const { data: tasks, isLoading } = tasksCrud.getAll.useQuery();
|
||||
const createTask = tasksCrud.create.useAction();
|
||||
const deleteTask = tasksCrud.delete.useAction();
|
||||
const updateTask = tasksCrud.update.useAction();
|
||||
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [editTaskTitle, setEditTaskTitle] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isEditing, setIsEditing] = useState<number | null>(null);
|
||||
|
||||
async function handleCreateTask(e: React.FormEvent) {
|
||||
setError("");
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createTask({
|
||||
title: newTaskTitle,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setError(`Error creating task: ${err as any}`);
|
||||
}
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
|
||||
async function handleUpdateTask(e: React.FormEvent) {
|
||||
setError("");
|
||||
e.preventDefault();
|
||||
try {
|
||||
await updateTask({ id: isEditing!, title: editTaskTitle });
|
||||
} catch (err: unknown) {
|
||||
setError("Error updating task.");
|
||||
}
|
||||
setIsEditing(null);
|
||||
setEditTaskTitle("");
|
||||
}
|
||||
|
||||
function handleStartEditing(task: Task) {
|
||||
setIsEditing(task.id);
|
||||
setEditTaskTitle(task.title);
|
||||
}
|
||||
|
||||
async function handleTaskDelete(task: { id: number }) {
|
||||
try {
|
||||
if (!confirm("Are you sure you want to delete this task?")) {
|
||||
return;
|
||||
}
|
||||
await deleteTask({ id: task.id });
|
||||
} catch (err: unknown) {
|
||||
setError("Error deleting task.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<h1>Tasks master</h1>
|
||||
<div className="error">{error}</div>
|
||||
<div className="tasks">
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{tasks?.map((task) => (
|
||||
<div key={task.id} className="task">
|
||||
{task.id === isEditing ? (
|
||||
<>
|
||||
<form className="new-task-form">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editTaskTitle}
|
||||
onChange={(e) => setEditTaskTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<button type="submit" onClick={handleUpdateTask}>
|
||||
Update task
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="task__title">
|
||||
<Link to={`/${task.id}`}>
|
||||
{JSON.stringify(task, null, 2)}
|
||||
</Link>
|
||||
</div>
|
||||
<button onClick={() => handleTaskDelete(task)}>Delete</button>
|
||||
<a onClick={() => handleStartEditing(task)}>
|
||||
<i>Edit</i>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{tasks?.length === 0 && <div>No tasks yet.</div>}
|
||||
</div>
|
||||
<form className="new-task-form">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<button type="submit" onClick={handleCreateTask}>
|
||||
Create task
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPage;
|
12
waspc/examples/crud-testing/src/client/SignupPage.tsx
Normal file
12
waspc/examples/crud-testing/src/client/SignupPage.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { SignupForm } from "@wasp/auth/forms/Signup";
|
||||
|
||||
export const SignupPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<h1>Signup</h1>
|
||||
<SignupForm />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
55
waspc/examples/crud-testing/src/client/tsconfig.json
Normal file
55
waspc/examples/crud-testing/src/client/tsconfig.json
Normal file
@ -0,0 +1,55 @@
|
||||
// =============================== IMPORTANT =================================
|
||||
//
|
||||
// This file is only used for Wasp IDE support. You can change it to configure
|
||||
// your IDE checks, but none of these options will affect the TypeScript
|
||||
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
|
||||
{
|
||||
"compilerOptions": {
|
||||
// JSX support
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
// Allow default imports.
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
// Wasp needs the following settings enable IDE support in your source
|
||||
// files. Editing them might break features like import autocompletion and
|
||||
// definition lookup. Don't change them unless you know what you're doing.
|
||||
//
|
||||
// The relative path to the generated web app's root directory. This must be
|
||||
// set to define the "paths" option.
|
||||
"baseUrl": "../../.wasp/out/web-app/",
|
||||
"paths": {
|
||||
// Resolve all "@wasp" imports to the generated source code.
|
||||
"@wasp/*": [
|
||||
"src/*"
|
||||
],
|
||||
// Resolve all non-relative imports to the correct node module. Source:
|
||||
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
|
||||
"*": [
|
||||
// Start by looking for the definiton inside the node modules root
|
||||
// directory...
|
||||
"node_modules/*",
|
||||
// ... If that fails, try to find it inside definitely-typed type
|
||||
// definitions.
|
||||
"node_modules/@types/*"
|
||||
]
|
||||
},
|
||||
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
|
||||
"typeRoots": [
|
||||
"../../.wasp/out/web-app/node_modules/@types"
|
||||
],
|
||||
// Since this TS config is used only for IDE support and not for
|
||||
// compilation, the following directory doesn't exist. We need to specify
|
||||
// it to prevent this error:
|
||||
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
|
||||
"outDir": "phantom"
|
||||
},
|
||||
"exclude": [
|
||||
"phantom"
|
||||
],
|
||||
}
|
1
waspc/examples/crud-testing/src/client/vite-env.d.ts
vendored
Normal file
1
waspc/examples/crud-testing/src/client/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="../../.wasp/out/web-app/node_modules/vite/client" />
|
BIN
waspc/examples/crud-testing/src/client/waspLogo.png
Normal file
BIN
waspc/examples/crud-testing/src/client/waspLogo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
52
waspc/examples/crud-testing/src/server/tasks.ts
Normal file
52
waspc/examples/crud-testing/src/server/tasks.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import HttpError from "@wasp/core/HttpError.js";
|
||||
import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks";
|
||||
import { Task, User } from "@wasp/entities";
|
||||
|
||||
export const getTask = (async (args, context) => {
|
||||
return context.entities.Task.findUnique({
|
||||
where: { id: args.id },
|
||||
include: {
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
}) satisfies GetQuery<
|
||||
{ id: Task["id"] },
|
||||
| (Task & {
|
||||
user: Pick<User, "username">;
|
||||
})
|
||||
| null
|
||||
>;
|
||||
|
||||
export const getAllTasks = (async (args, context) => {
|
||||
return context.entities.Task.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}) satisfies GetAllQuery<{}, {}>;
|
||||
|
||||
export const createTask = (async (args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, "You must be logged in to create a task.");
|
||||
}
|
||||
if (!args.title) {
|
||||
throw new HttpError(400, "Task title is required.");
|
||||
}
|
||||
return context.entities.Task.create({
|
||||
data: {
|
||||
title: args.title!,
|
||||
user: {
|
||||
connect: {
|
||||
id: context.user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}) satisfies CreateAction<{ title: Task["title"] }, Task>;
|
48
waspc/examples/crud-testing/src/server/tsconfig.json
Normal file
48
waspc/examples/crud-testing/src/server/tsconfig.json
Normal file
@ -0,0 +1,48 @@
|
||||
// =============================== IMPORTANT =================================
|
||||
//
|
||||
// This file is only used for Wasp IDE support. You can change it to configure
|
||||
// your IDE checks, but none of these options will affect the TypeScript
|
||||
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Allows default imports.
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
// Wasp needs the following settings enable IDE support in your source
|
||||
// files. Editing them might break features like import autocompletion and
|
||||
// definition lookup. Don't change them unless you know what you're doing.
|
||||
//
|
||||
// The relative path to the generated web app's root directory. This must be
|
||||
// set to define the "paths" option.
|
||||
"baseUrl": "../../.wasp/out/server/",
|
||||
"paths": {
|
||||
// Resolve all "@wasp" imports to the generated source code.
|
||||
"@wasp/*": [
|
||||
"src/*"
|
||||
],
|
||||
// Resolve all non-relative imports to the correct node module. Source:
|
||||
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
|
||||
"*": [
|
||||
// Start by looking for the definiton inside the node modules root
|
||||
// directory...
|
||||
"node_modules/*",
|
||||
// ... If that fails, try to find it inside definitely-typed type
|
||||
// definitions.
|
||||
"node_modules/@types/*"
|
||||
]
|
||||
},
|
||||
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
|
||||
"typeRoots": [
|
||||
"../../.wasp/out/server/node_modules/@types"
|
||||
],
|
||||
// Since this TS config is used only for IDE support and not for
|
||||
// compilation, the following directory doesn't exist. We need to specify
|
||||
// it to prevent this error:
|
||||
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
|
||||
"outDir": "phantom",
|
||||
},
|
||||
"exclude": [
|
||||
"phantom"
|
||||
],
|
||||
}
|
28
waspc/examples/crud-testing/src/shared/tsconfig.json
Normal file
28
waspc/examples/crud-testing/src/shared/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable default imports in TypeScript.
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
// The following settings enable IDE support in user-provided source files.
|
||||
// Editing them might break features like import autocompletion and
|
||||
// definition lookup. Don't change them unless you know what you're doing.
|
||||
//
|
||||
// The relative path to the generated web app's root directory. This must be
|
||||
// set to define the "paths" option.
|
||||
"baseUrl": "../../.wasp/out/server/",
|
||||
"paths": {
|
||||
// Resolve all non-relative imports to the correct node module. Source:
|
||||
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
|
||||
"*": [
|
||||
// Start by looking for the definiton inside the node modules root
|
||||
// directory...
|
||||
"node_modules/*",
|
||||
// ... If that fails, try to find it inside definitely-typed type
|
||||
// definitions.
|
||||
"node_modules/@types/*"
|
||||
]
|
||||
},
|
||||
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
|
||||
"typeRoots": ["../../.wasp/out/server/node_modules/@types"]
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@ import Wasp.AppSpec.ApiNamespace (ApiNamespace)
|
||||
import Wasp.AppSpec.App (App)
|
||||
import Wasp.AppSpec.App.Db (DbSystem)
|
||||
import Wasp.AppSpec.App.EmailSender (EmailProvider)
|
||||
import Wasp.AppSpec.Crud (Crud)
|
||||
import Wasp.AppSpec.Entity (Entity)
|
||||
import Wasp.AppSpec.Job (Job, JobExecutor)
|
||||
import Wasp.AppSpec.Page (Page)
|
||||
@ -33,6 +34,7 @@ makeDeclType ''Job
|
||||
makeEnumType ''HttpMethod
|
||||
makeDeclType ''Api
|
||||
makeDeclType ''ApiNamespace
|
||||
makeDeclType ''Crud
|
||||
makeDeclType ''App
|
||||
|
||||
{- ORMOLU_DISABLE -}
|
||||
@ -54,5 +56,6 @@ stdTypes =
|
||||
TD.addDeclType @Api $
|
||||
TD.addDeclType @ApiNamespace $
|
||||
TD.addEnumType @EmailProvider $
|
||||
TD.addDeclType @Crud $
|
||||
TD.empty
|
||||
{- ORMOLU_ENABLE -}
|
||||
|
@ -19,6 +19,7 @@ module Wasp.AppSpec
|
||||
asAbsWaspProjectDirFile,
|
||||
getApp,
|
||||
getApiNamespaces,
|
||||
getCruds,
|
||||
)
|
||||
where
|
||||
|
||||
@ -33,6 +34,7 @@ import Wasp.AppSpec.App (App)
|
||||
import Wasp.AppSpec.ConfigFile (ConfigFileRelocator (..))
|
||||
import Wasp.AppSpec.Core.Decl (Decl, IsDecl, takeDecls)
|
||||
import Wasp.AppSpec.Core.Ref (Ref, refName)
|
||||
import Wasp.AppSpec.Crud (Crud)
|
||||
import Wasp.AppSpec.Entity (Entity)
|
||||
import qualified Wasp.AppSpec.ExternalCode as ExternalCode
|
||||
import Wasp.AppSpec.Job (Job)
|
||||
@ -96,6 +98,9 @@ getApis = getDecls
|
||||
getApiNamespaces :: AppSpec -> [(String, ApiNamespace)]
|
||||
getApiNamespaces = getDecls
|
||||
|
||||
getCruds :: AppSpec -> [(String, Crud)]
|
||||
getCruds = getDecls
|
||||
|
||||
getEntities :: AppSpec -> [(String, Entity)]
|
||||
getEntities = getDecls
|
||||
|
||||
@ -117,10 +122,13 @@ resolveRef :: (IsDecl d) => AppSpec -> Ref d -> (String, d)
|
||||
resolveRef spec ref =
|
||||
fromMaybe
|
||||
( error $
|
||||
"Failed to resolve declaration reference: " ++ refName ref ++ "."
|
||||
"Failed to resolve declaration reference: "
|
||||
++ refName ref
|
||||
++ "."
|
||||
++ " This should never happen, as Analyzer should ensure all references in AppSpec are valid."
|
||||
)
|
||||
$ find ((== refName ref) . fst) $ getDecls spec
|
||||
$ find ((== refName ref) . fst) $
|
||||
getDecls spec
|
||||
|
||||
doesConfigFileExist :: AppSpec -> Path' (Rel WaspProjectDir) File' -> Bool
|
||||
doesConfigFileExist spec file =
|
||||
|
41
waspc/src/Wasp/AppSpec/Crud.hs
Normal file
41
waspc/src/Wasp/AppSpec/Crud.hs
Normal file
@ -0,0 +1,41 @@
|
||||
{-# LANGUAGE DeriveDataTypeable #-}
|
||||
|
||||
module Wasp.AppSpec.Crud
|
||||
( Crud (..),
|
||||
CrudOperations (..),
|
||||
CrudOperation (..),
|
||||
CrudOperationOptions (..),
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Data (Data)
|
||||
import Wasp.AppSpec.Core.Decl (IsDecl)
|
||||
import Wasp.AppSpec.Core.Ref (Ref)
|
||||
import Wasp.AppSpec.Entity (Entity)
|
||||
import Wasp.AppSpec.ExtImport (ExtImport)
|
||||
|
||||
data Crud = Crud
|
||||
{ entity :: Ref Entity,
|
||||
operations :: CrudOperations
|
||||
}
|
||||
deriving (Show, Eq, Data)
|
||||
|
||||
instance IsDecl Crud
|
||||
|
||||
data CrudOperations = CrudOperations
|
||||
{ get :: Maybe CrudOperationOptions,
|
||||
getAll :: Maybe CrudOperationOptions,
|
||||
create :: Maybe CrudOperationOptions,
|
||||
update :: Maybe CrudOperationOptions,
|
||||
delete :: Maybe CrudOperationOptions
|
||||
}
|
||||
deriving (Show, Eq, Data)
|
||||
|
||||
data CrudOperationOptions = CrudOperationOptions
|
||||
{ isPublic :: Maybe Bool,
|
||||
overrideFn :: Maybe ExtImport
|
||||
}
|
||||
deriving (Show, Eq, Data)
|
||||
|
||||
data CrudOperation = Get | GetAll | Create | Update | Delete
|
||||
deriving (Show, Eq, Ord, Data)
|
@ -5,6 +5,8 @@ module Wasp.AppSpec.Entity
|
||||
Entity,
|
||||
getFields,
|
||||
getPslModelBody,
|
||||
getIdField,
|
||||
getIdBlockAttribute,
|
||||
)
|
||||
where
|
||||
|
||||
@ -13,6 +15,7 @@ import Wasp.AppSpec.Core.Decl (IsDecl)
|
||||
import Wasp.AppSpec.Entity.Field (Field)
|
||||
import qualified Wasp.AppSpec.Entity.Field as Field
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
import Wasp.Psl.Util (findIdBlockAttribute, findIdField)
|
||||
|
||||
data Entity = Entity
|
||||
{ fields :: ![Field],
|
||||
@ -38,3 +41,9 @@ getFields = fields
|
||||
|
||||
getPslModelBody :: Entity -> PslModel.Body
|
||||
getPslModelBody = pslModelBody
|
||||
|
||||
getIdField :: Entity -> Maybe PslModel.Field
|
||||
getIdField = findIdField . getPslModelBody
|
||||
|
||||
getIdBlockAttribute :: Entity -> Maybe PslModel.Attribute
|
||||
getIdBlockAttribute = findIdBlockAttribute . getPslModelBody
|
||||
|
@ -6,12 +6,13 @@ module Wasp.AppSpec.Valid
|
||||
getApp,
|
||||
isAuthEnabled,
|
||||
doesUserEntityContainField,
|
||||
getIdFieldFromCrudEntity,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Data.List (find, group, groupBy, intercalate, sort, sortBy)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.Maybe (fromJust, isJust)
|
||||
import Text.Read (readMaybe)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
@ -25,10 +26,13 @@ import qualified Wasp.AppSpec.App.Auth as Auth
|
||||
import qualified Wasp.AppSpec.App.Db as AS.Db
|
||||
import qualified Wasp.AppSpec.App.Wasp as Wasp
|
||||
import Wasp.AppSpec.Core.Decl (takeDecls)
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
import qualified Wasp.AppSpec.Entity as Entity
|
||||
import qualified Wasp.AppSpec.Entity.Field as Entity.Field
|
||||
import qualified Wasp.AppSpec.Page as Page
|
||||
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
|
||||
import Wasp.Generator.Crud (crudDeclarationToOperationsList)
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
import qualified Wasp.SemanticVersion as SV
|
||||
import qualified Wasp.Version as WV
|
||||
|
||||
@ -54,7 +58,8 @@ validateAppSpec spec =
|
||||
validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec,
|
||||
validateDbIsPostgresIfPgBossUsed spec,
|
||||
validateApiRoutesAreUnique spec,
|
||||
validateApiNamespacePathsAreUnique spec
|
||||
validateApiNamespacePathsAreUnique spec,
|
||||
validateCrudOperations spec
|
||||
]
|
||||
|
||||
validateExactlyOneAppExists :: AppSpec -> Maybe ValidationError
|
||||
@ -74,7 +79,8 @@ validateWaspVersion :: String -> [ValidationError]
|
||||
validateWaspVersion specWaspVersionStr = eitherUnitToErrorList $ do
|
||||
specWaspVersionRange <- parseWaspVersionRange specWaspVersionStr
|
||||
unless (SV.isVersionInRange WV.waspVersion specWaspVersionRange) $
|
||||
Left $ incompatibleVersionError WV.waspVersion specWaspVersionRange
|
||||
Left $
|
||||
incompatibleVersionError WV.waspVersion specWaspVersionRange
|
||||
where
|
||||
-- TODO: Use version range parser from SemanticVersion when it is fully implemented.
|
||||
|
||||
@ -250,6 +256,33 @@ validateApiNamespacePathsAreUnique spec =
|
||||
namespacePaths = AS.ApiNamespace.path . snd <$> AS.getApiNamespaces spec
|
||||
duplicatePaths = map head $ filter ((> 1) . length) (group . sort $ namespacePaths)
|
||||
|
||||
validateCrudOperations :: AppSpec -> [ValidationError]
|
||||
validateCrudOperations spec =
|
||||
concat
|
||||
[ concatMap checkIfAtLeastOneOperationIsUsedForCrud cruds,
|
||||
concatMap checkIfSimpleIdFieldIsDefinedForEntity cruds
|
||||
]
|
||||
where
|
||||
cruds = AS.getCruds spec
|
||||
|
||||
checkIfAtLeastOneOperationIsUsedForCrud :: (String, AS.Crud.Crud) -> [ValidationError]
|
||||
checkIfAtLeastOneOperationIsUsedForCrud (crudName, crud) =
|
||||
if not . null $ crudOperations
|
||||
then []
|
||||
else [GenericValidationError $ "CRUD \"" ++ crudName ++ "\" must have at least one operation defined."]
|
||||
where
|
||||
crudOperations = crudDeclarationToOperationsList crud
|
||||
|
||||
checkIfSimpleIdFieldIsDefinedForEntity :: (String, AS.Crud.Crud) -> [ValidationError]
|
||||
checkIfSimpleIdFieldIsDefinedForEntity (crudName, crud) = case (maybeIdField, maybeIdBlockAttribute) of
|
||||
(Just _, Nothing) -> []
|
||||
(Nothing, Just _) -> [GenericValidationError $ "Entity referenced by \"" ++ crudName ++ "\" CRUD declaration must have an ID field (marked with @id attribute) and not a composite ID (defined with @@id attribute)."]
|
||||
_missingIdFieldWithoutBlockIdAttributeDefined -> [GenericValidationError $ "Entity referenced by \"" ++ crudName ++ "\" CRUD declaration must have an ID field (marked with @id attribute)."]
|
||||
where
|
||||
maybeIdField = Entity.getIdField entity
|
||||
maybeIdBlockAttribute = Entity.getIdBlockAttribute entity
|
||||
(_, entity) = AS.resolveRef spec (AS.Crud.entity crud)
|
||||
|
||||
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
|
||||
-- TODO: It would be great if we could ensure this at type level, but we decided that was too much work for now.
|
||||
-- Check https://github.com/wasp-lang/wasp/pull/455 for considerations on this and analysis of different approaches.
|
||||
@ -281,3 +314,10 @@ doesUserEntityContainField spec fieldName = do
|
||||
let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth)
|
||||
let userEntityFields = Entity.getFields userEntity
|
||||
Just $ any (\field -> Entity.Field.fieldName field == fieldName) userEntityFields
|
||||
|
||||
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
|
||||
-- We validated that entity field exists, so we can safely use fromJust here.
|
||||
getIdFieldFromCrudEntity :: AppSpec -> AS.Crud.Crud -> PslModel.Field
|
||||
getIdFieldFromCrudEntity spec crud = fromJust $ Entity.getIdField crudEntity
|
||||
where
|
||||
crudEntity = snd $ AS.resolveRef spec (AS.Crud.entity crud)
|
||||
|
71
waspc/src/Wasp/Generator/Crud.hs
Normal file
71
waspc/src/Wasp/Generator/Crud.hs
Normal file
@ -0,0 +1,71 @@
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
|
||||
|
||||
module Wasp.Generator.Crud
|
||||
( getCrudOperationJson,
|
||||
getCrudFilePath,
|
||||
makeCrudOperationKeyAndJsonPair,
|
||||
crudDeclarationToOperationsList,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.Aeson.Types as Aeson.Types
|
||||
import Data.Maybe (catMaybes, fromJust, fromMaybe)
|
||||
import qualified Data.Text as T
|
||||
import StrongPath (File', Path', Rel)
|
||||
import qualified StrongPath as SP
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
import Wasp.Generator.Common (makeJsArrayFromHaskellList)
|
||||
import qualified Wasp.Generator.Crud.Routes as Routes
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
import qualified Wasp.Util as Util
|
||||
|
||||
getCrudOperationJson :: String -> AS.Crud.Crud -> PslModel.Field -> Aeson.Value
|
||||
getCrudOperationJson crudOperationName crud idField =
|
||||
object
|
||||
[ "name" .= crudOperationName,
|
||||
"operations" .= object (map getDataForOperation crudOperations),
|
||||
"entityUpper" .= crudEntityName,
|
||||
"entityLower" .= Util.toLowerFirst crudEntityName,
|
||||
"entitiesArray" .= makeJsArrayFromHaskellList [crudEntityName],
|
||||
"idFieldName" .= PslModel._name idField
|
||||
]
|
||||
where
|
||||
crudEntityName = AS.refName $ AS.Crud.entity crud
|
||||
|
||||
crudOperations = crudDeclarationToOperationsList crud
|
||||
|
||||
getDataForOperation :: (AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions) -> Aeson.Types.Pair
|
||||
getDataForOperation (operation, options) =
|
||||
makeCrudOperationKeyAndJsonPair
|
||||
operation
|
||||
( object
|
||||
[ "route" .= Routes.getRoute operation,
|
||||
"fullPath" .= Routes.makeFullPath crudOperationName operation,
|
||||
"isPublic" .= fromMaybe False (AS.Crud.isPublic options)
|
||||
]
|
||||
)
|
||||
|
||||
getCrudFilePath :: String -> String -> Path' (Rel r) File'
|
||||
getCrudFilePath crudName ext = fromJust (SP.parseRelFile (crudName ++ "." ++ ext))
|
||||
|
||||
crudDeclarationToOperationsList :: AS.Crud.Crud -> [(AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions)]
|
||||
crudDeclarationToOperationsList crud =
|
||||
catMaybes
|
||||
[ fmap (AS.Crud.Get,) (AS.Crud.get $ AS.Crud.operations crud),
|
||||
fmap (AS.Crud.GetAll,) (AS.Crud.getAll $ AS.Crud.operations crud),
|
||||
fmap (AS.Crud.Create,) (AS.Crud.create $ AS.Crud.operations crud),
|
||||
fmap (AS.Crud.Update,) (AS.Crud.update $ AS.Crud.operations crud),
|
||||
fmap (AS.Crud.Delete,) (AS.Crud.delete $ AS.Crud.operations crud)
|
||||
]
|
||||
|
||||
-- Produces a pair of the operation name and arbitrary json value.
|
||||
-- For example, for operation CrudOperation.Get and json value { "route": "get" },
|
||||
-- this function will produce a pair ("Get", { "route": "get" }).
|
||||
makeCrudOperationKeyAndJsonPair :: AS.Crud.CrudOperation -> Aeson.Value -> Aeson.Types.Pair
|
||||
makeCrudOperationKeyAndJsonPair operation json = key .= json
|
||||
where
|
||||
key = T.pack . show $ operation
|
18
waspc/src/Wasp/Generator/Crud/Routes.hs
Normal file
18
waspc/src/Wasp/Generator/Crud/Routes.hs
Normal file
@ -0,0 +1,18 @@
|
||||
module Wasp.Generator.Crud.Routes where
|
||||
|
||||
import Data.List (intercalate)
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
|
||||
getRoute :: AS.Crud.CrudOperation -> String
|
||||
getRoute operation = case operation of
|
||||
AS.Crud.Get -> "get"
|
||||
AS.Crud.GetAll -> "get-all"
|
||||
AS.Crud.Create -> "create"
|
||||
AS.Crud.Update -> "update"
|
||||
AS.Crud.Delete -> "delete"
|
||||
|
||||
makeFullPath :: String -> AS.Crud.CrudOperation -> String
|
||||
makeFullPath crudOperationName crudOperation = intercalate "/" [getCrudOperationRouterRoute crudOperationName, getRoute crudOperation]
|
||||
|
||||
getCrudOperationRouterRoute :: String -> String
|
||||
getCrudOperationRouterRoute crudOperationName = crudOperationName
|
@ -32,7 +32,7 @@ import StrongPath
|
||||
relfile,
|
||||
(</>),
|
||||
)
|
||||
import Wasp.AppSpec (AppSpec, getApis)
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
|
||||
@ -56,6 +56,7 @@ import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport)
|
||||
import Wasp.Generator.ServerGenerator.AuthG (genAuth)
|
||||
import qualified Wasp.Generator.ServerGenerator.Common as C
|
||||
import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile)
|
||||
import Wasp.Generator.ServerGenerator.CrudG (genCrud)
|
||||
import Wasp.Generator.ServerGenerator.Db.Seed (genDbSeed, getPackageJsonPrismaSeedField)
|
||||
import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender)
|
||||
import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy)
|
||||
@ -89,6 +90,7 @@ genServer spec =
|
||||
<++> genEnvValidationScript
|
||||
<++> genExportedTypesDir spec
|
||||
<++> genApis spec
|
||||
<++> genCrud spec
|
||||
where
|
||||
genFileCopy = return . C.mkTmplFd
|
||||
|
||||
@ -258,18 +260,23 @@ genRoutesDir :: AppSpec -> Generator [FileDraft]
|
||||
genRoutesDir spec =
|
||||
-- TODO(martin): We will probably want to extract "routes" path here same as we did with "src", to avoid hardcoding,
|
||||
-- but I did not bother with it yet since it is used only here for now.
|
||||
return
|
||||
[ C.mkTmplFdWithDstAndData
|
||||
(C.asTmplFile [relfile|src/routes/index.js|])
|
||||
(C.asServerFile [relfile|src/routes/index.js|])
|
||||
( Just $
|
||||
object
|
||||
[ "operationsRouteInRootRouter" .= (operationsRouteInRootRouter :: String),
|
||||
"isAuthEnabled" .= (isAuthEnabled spec :: Bool),
|
||||
"areThereAnyCustomApiRoutes" .= (not . null $ getApis spec)
|
||||
]
|
||||
)
|
||||
]
|
||||
sequence [genRoutesIndex spec]
|
||||
|
||||
genRoutesIndex :: AppSpec -> Generator FileDraft
|
||||
genRoutesIndex spec =
|
||||
return $
|
||||
C.mkTmplFdWithDstAndData
|
||||
(C.asTmplFile [relfile|src/routes/index.js|])
|
||||
(C.asServerFile [relfile|src/routes/index.js|])
|
||||
(Just tmplData)
|
||||
where
|
||||
tmplData =
|
||||
object
|
||||
[ "operationsRouteInRootRouter" .= (operationsRouteInRootRouter :: String),
|
||||
"isAuthEnabled" .= (isAuthEnabled spec :: Bool),
|
||||
"areThereAnyCustomApiRoutes" .= (not . null $ AS.getApis spec),
|
||||
"areThereAnyCrudRoutes" .= (not . null $ AS.getCruds spec)
|
||||
]
|
||||
|
||||
genTypesAndEntitiesDirs :: AppSpec -> Generator [FileDraft]
|
||||
genTypesAndEntitiesDirs spec =
|
||||
@ -383,9 +390,10 @@ genExportedTypesDir spec =
|
||||
|
||||
genMiddleware :: AppSpec -> Generator [FileDraft]
|
||||
genMiddleware spec =
|
||||
return
|
||||
[ C.mkTmplFd [relfile|src/middleware/index.ts|],
|
||||
C.mkTmplFdWithData [relfile|src/middleware/globalMiddleware.ts|] (Just tmplData)
|
||||
sequence
|
||||
[ return $ C.mkTmplFd [relfile|src/middleware/index.ts|],
|
||||
return $ C.mkTmplFdWithData [relfile|src/middleware/globalMiddleware.ts|] (Just tmplData),
|
||||
genOperationsMiddleware spec
|
||||
]
|
||||
where
|
||||
tmplData =
|
||||
@ -403,3 +411,13 @@ genMiddleware spec =
|
||||
"importStatement" .= maybe "" fst maybeGlobalMidlewareConfigFnImports,
|
||||
"importAlias" .= globalMiddlewareConfigFnAlias
|
||||
]
|
||||
|
||||
genOperationsMiddleware :: AppSpec -> Generator FileDraft
|
||||
genOperationsMiddleware spec =
|
||||
return $
|
||||
C.mkTmplFdWithDstAndData
|
||||
(C.asTmplFile [relfile|src/middleware/operations.ts|])
|
||||
(C.asServerFile [relfile|src/middleware/operations.ts|])
|
||||
(Just tmplData)
|
||||
where
|
||||
tmplData = object ["isAuthEnabled" .= (isAuthEnabled spec :: Bool)]
|
||||
|
117
waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs
Normal file
117
waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs
Normal file
@ -0,0 +1,117 @@
|
||||
module Wasp.Generator.ServerGenerator.CrudG
|
||||
( genCrud,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Aeson
|
||||
import qualified Data.Aeson.Types as Aeson.Types
|
||||
import Data.Maybe (fromJust)
|
||||
import StrongPath (reldir, reldirP, relfile, (</>))
|
||||
import qualified StrongPath as SP
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App.Auth as AS.Auth
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
import Wasp.AppSpec.Valid (getApp, getIdFieldFromCrudEntity, isAuthEnabled)
|
||||
import Wasp.Generator.Crud
|
||||
( crudDeclarationToOperationsList,
|
||||
getCrudFilePath,
|
||||
getCrudOperationJson,
|
||||
makeCrudOperationKeyAndJsonPair,
|
||||
)
|
||||
import qualified Wasp.Generator.Crud.Routes as Routes
|
||||
import Wasp.Generator.FileDraft (FileDraft)
|
||||
import Wasp.Generator.Monad (Generator)
|
||||
import qualified Wasp.Generator.ServerGenerator.Common as C
|
||||
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson)
|
||||
import qualified Wasp.JsImport as JI
|
||||
import Wasp.Util ((<++>))
|
||||
|
||||
genCrud :: AppSpec -> Generator [FileDraft]
|
||||
genCrud spec =
|
||||
if areThereAnyCruds
|
||||
then
|
||||
sequence [genCrudIndexRoute cruds]
|
||||
<++> genCrudRoutes spec cruds
|
||||
<++> genCrudOperations spec cruds
|
||||
else return []
|
||||
where
|
||||
cruds = AS.getCruds spec
|
||||
areThereAnyCruds = not . null $ cruds
|
||||
|
||||
genCrudIndexRoute :: [(String, AS.Crud.Crud)] -> Generator FileDraft
|
||||
genCrudIndexRoute cruds = return $ C.mkTmplFdWithData tmplPath (Just tmplData)
|
||||
where
|
||||
tmplPath = [relfile|src/routes/crud/index.ts|]
|
||||
tmplData = object ["crudRouters" .= map getCrudRouterData cruds]
|
||||
|
||||
getCrudRouterData :: (String, AS.Crud.Crud) -> Data.Aeson.Value
|
||||
getCrudRouterData (name, _) =
|
||||
object
|
||||
[ "importStatement" .= importStatement,
|
||||
"importIdentifier" .= importIdentifier,
|
||||
"route" .= Routes.getCrudOperationRouterRoute name
|
||||
]
|
||||
where
|
||||
(importStatement, importIdentifier) =
|
||||
JI.getJsImportStmtAndIdentifier
|
||||
JI.JsImport
|
||||
{ JI._name = JI.JsImportField name,
|
||||
JI._path = fromJust . SP.relFileToPosix $ getCrudFilePath name "js",
|
||||
JI._importAlias = Nothing
|
||||
}
|
||||
|
||||
genCrudRoutes :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
|
||||
genCrudRoutes spec cruds = return $ map genCrudRoute cruds
|
||||
where
|
||||
genCrudRoute :: (String, AS.Crud.Crud) -> FileDraft
|
||||
genCrudRoute (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
|
||||
where
|
||||
tmplPath = [relfile|src/routes/crud/_crud.ts|]
|
||||
destPath = C.serverSrcDirInServerRootDir </> [reldir|routes/crud|] </> getCrudFilePath name "ts"
|
||||
tmplData =
|
||||
object
|
||||
[ "crud" .= getCrudOperationJson name crud idField,
|
||||
"isAuthEnabled" .= isAuthEnabled spec
|
||||
]
|
||||
-- We validated in analyzer that entity field exists, so we can safely use fromJust here.
|
||||
idField = getIdFieldFromCrudEntity spec crud
|
||||
|
||||
genCrudOperations :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
|
||||
genCrudOperations spec cruds = return $ map genCrudOperation cruds
|
||||
where
|
||||
genCrudOperation :: (String, AS.Crud.Crud) -> FileDraft
|
||||
genCrudOperation (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
|
||||
where
|
||||
tmplPath = [relfile|src/crud/_operations.ts|]
|
||||
destPath = C.serverSrcDirInServerRootDir </> [reldir|crud|] </> getCrudFilePath name "ts"
|
||||
tmplData =
|
||||
object
|
||||
[ "crud" .= getCrudOperationJson name crud idField,
|
||||
"isAuthEnabled" .= isAuthEnabled spec,
|
||||
"userEntityUpper" .= maybeUserEntity,
|
||||
"overrides" .= object overrides,
|
||||
"queryType" .= queryTsType,
|
||||
"actionType" .= actionTsType
|
||||
]
|
||||
idField = getIdFieldFromCrudEntity spec crud
|
||||
maybeUserEntity = AS.refName . AS.Auth.userEntity <$> maybeAuth
|
||||
maybeAuth = AS.App.auth $ snd $ getApp spec
|
||||
|
||||
queryTsType :: String
|
||||
queryTsType = if isAuthEnabled spec then "AuthenticatedQuery" else "Query"
|
||||
|
||||
actionTsType :: String
|
||||
actionTsType = if isAuthEnabled spec then "AuthenticatedAction" else "Action"
|
||||
|
||||
overrides :: [Aeson.Types.Pair]
|
||||
overrides = map operationToOverrideImport crudOperations
|
||||
|
||||
crudOperations = crudDeclarationToOperationsList crud
|
||||
|
||||
operationToOverrideImport :: (AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions) -> Aeson.Types.Pair
|
||||
operationToOverrideImport (operation, options) = makeCrudOperationKeyAndJsonPair operation importJson
|
||||
where
|
||||
importJson = extImportToImportJson [reldirP|../|] (AS.Crud.overrideFn options)
|
@ -7,18 +7,15 @@ module Wasp.Generator.ServerGenerator.OperationsRoutesG
|
||||
where
|
||||
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Aeson as Aeson
|
||||
import Data.Maybe (fromJust, fromMaybe, isJust)
|
||||
import StrongPath (Dir, File', Path, Path', Posix, Rel, reldir, reldirP, relfile, (</>))
|
||||
import qualified StrongPath as SP
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.Action as AS.Action
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App.Auth as AS.Auth
|
||||
import qualified Wasp.AppSpec.Operation as AS.Operation
|
||||
import qualified Wasp.AppSpec.Query as AS.Query
|
||||
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
|
||||
import Wasp.AppSpec.Valid (isAuthEnabled)
|
||||
import Wasp.Generator.Common (ServerRootDir)
|
||||
import Wasp.Generator.FileDraft (FileDraft)
|
||||
import Wasp.Generator.Monad (Generator, GeneratorError (GenericGeneratorError), logAndThrowGeneratorError)
|
||||
@ -30,42 +27,37 @@ import qualified Wasp.Util as U
|
||||
genOperationsRoutes :: AppSpec -> Generator [FileDraft]
|
||||
genOperationsRoutes spec =
|
||||
sequence . concat $
|
||||
[ map (genActionRoute spec) (AS.getActions spec),
|
||||
map (genQueryRoute spec) (AS.getQueries spec),
|
||||
[ map genActionRoute (AS.getActions spec),
|
||||
map genQueryRoute (AS.getQueries spec),
|
||||
[genOperationsRouter spec]
|
||||
]
|
||||
|
||||
genActionRoute :: AppSpec -> (String, AS.Action.Action) -> Generator FileDraft
|
||||
genActionRoute spec (actionName, action) = genOperationRoute spec op tmplFile
|
||||
genActionRoute :: (String, AS.Action.Action) -> Generator FileDraft
|
||||
genActionRoute (actionName, action) = genOperationRoute op tmplFile
|
||||
where
|
||||
op = AS.Operation.ActionOp actionName action
|
||||
tmplFile = C.asTmplFile [relfile|src/routes/operations/_action.js|]
|
||||
|
||||
genQueryRoute :: AppSpec -> (String, AS.Query.Query) -> Generator FileDraft
|
||||
genQueryRoute spec (queryName, query) = genOperationRoute spec op tmplFile
|
||||
genQueryRoute :: (String, AS.Query.Query) -> Generator FileDraft
|
||||
genQueryRoute (queryName, query) = genOperationRoute op tmplFile
|
||||
where
|
||||
op = AS.Operation.QueryOp queryName query
|
||||
tmplFile = C.asTmplFile [relfile|src/routes/operations/_query.js|]
|
||||
|
||||
genOperationRoute :: AppSpec -> AS.Operation.Operation -> Path' (Rel C.ServerTemplatesDir) File' -> Generator FileDraft
|
||||
genOperationRoute spec operation tmplFile = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
|
||||
genOperationRoute :: AS.Operation.Operation -> Path' (Rel C.ServerTemplatesDir) File' -> Generator FileDraft
|
||||
genOperationRoute operation tmplFile = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
|
||||
where
|
||||
dstFile = operationsRoutesDirInServerRootDir </> operationRouteFileInOperationsRoutesDir operation
|
||||
|
||||
baseTmplData =
|
||||
tmplData =
|
||||
object
|
||||
[ "operationName" .= (operationImportIdentifier :: String),
|
||||
"operationImportStmt" .= (operationImportStmt :: String)
|
||||
[ "operation"
|
||||
.= object
|
||||
[ "importIdentifier" .= (operationImportIdentifier :: String),
|
||||
"importStatement" .= (operationImportStmt :: String)
|
||||
]
|
||||
]
|
||||
|
||||
tmplData = case AS.App.auth (snd $ getApp spec) of
|
||||
Nothing -> baseTmplData
|
||||
Just auth ->
|
||||
U.jsonSet
|
||||
"userEntityLower"
|
||||
(Aeson.toJSON (U.toLowerFirst $ AS.refName $ AS.Auth.userEntity auth))
|
||||
baseTmplData
|
||||
|
||||
pathToOperationFile =
|
||||
relPosixPathFromOperationsRoutesDirToSrcDir
|
||||
</> fromJust (SP.relFileToPosix $ operationFileInSrcDir operation)
|
||||
|
@ -38,6 +38,7 @@ import Wasp.Generator.Monad (Generator)
|
||||
import qualified Wasp.Generator.NpmDependencies as N
|
||||
import Wasp.Generator.WebAppGenerator.AuthG (genAuth)
|
||||
import qualified Wasp.Generator.WebAppGenerator.Common as C
|
||||
import Wasp.Generator.WebAppGenerator.CrudG (genCrud)
|
||||
import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator
|
||||
( extClientCodeGeneratorStrategy,
|
||||
extSharedCodeGeneratorStrategy,
|
||||
@ -72,6 +73,7 @@ genWebApp spec = do
|
||||
<++> genDotEnv spec
|
||||
<++> genUniversalDir
|
||||
<++> genEnvValidationScript
|
||||
<++> genCrud spec
|
||||
where
|
||||
genFileCopy = return . C.mkTmplFd
|
||||
|
||||
|
35
waspc/src/Wasp/Generator/WebAppGenerator/CrudG.hs
Normal file
35
waspc/src/Wasp/Generator/WebAppGenerator/CrudG.hs
Normal file
@ -0,0 +1,35 @@
|
||||
module Wasp.Generator.WebAppGenerator.CrudG
|
||||
( genCrud,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Maybe (fromJust)
|
||||
import StrongPath (reldir, relfile, (</>))
|
||||
import qualified StrongPath as SP
|
||||
import Wasp.AppSpec (AppSpec, getCruds)
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
import Wasp.AppSpec.Valid (getIdFieldFromCrudEntity)
|
||||
import Wasp.Generator.Crud (getCrudOperationJson)
|
||||
import Wasp.Generator.FileDraft (FileDraft)
|
||||
import Wasp.Generator.Monad (Generator)
|
||||
import qualified Wasp.Generator.WebAppGenerator.Common as C
|
||||
|
||||
genCrud :: AppSpec -> Generator [FileDraft]
|
||||
genCrud spec =
|
||||
if areThereAnyCruds
|
||||
then genCrudOperations spec cruds
|
||||
else return []
|
||||
where
|
||||
cruds = getCruds spec
|
||||
areThereAnyCruds = not $ null cruds
|
||||
|
||||
genCrudOperations :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
|
||||
genCrudOperations spec cruds = return $ map genCrudOperation cruds
|
||||
where
|
||||
genCrudOperation :: (String, AS.Crud.Crud) -> FileDraft
|
||||
genCrudOperation (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
|
||||
where
|
||||
tmplPath = [relfile|src/crud/_crud.ts|]
|
||||
destPath = C.webAppSrcDirInWebAppRootDir </> [reldir|crud|] </> fromJust (SP.parseRelFile (name ++ ".ts"))
|
||||
tmplData = getCrudOperationJson name crud idField
|
||||
idField = getIdFieldFromCrudEntity spec crud
|
28
waspc/src/Wasp/Psl/Util.hs
Normal file
28
waspc/src/Wasp/Psl/Util.hs
Normal file
@ -0,0 +1,28 @@
|
||||
module Wasp.Psl.Util where
|
||||
|
||||
import Data.Foldable (find)
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
|
||||
findIdField :: PslModel.Body -> Maybe PslModel.Field
|
||||
findIdField (PslModel.Body elements) = find isIdField fields
|
||||
where
|
||||
fields = [field | (PslModel.ElementField field) <- elements]
|
||||
|
||||
isIdField :: PslModel.Field -> Bool
|
||||
isIdField PslModel.Field {_attrs = attrs} = any (\attr -> PslModel._attrName attr == attrNameAssociatedWitIdField) attrs
|
||||
|
||||
-- We define an ID field as a field that has the @id attribute.
|
||||
attrNameAssociatedWitIdField :: String
|
||||
attrNameAssociatedWitIdField = "id"
|
||||
|
||||
findIdBlockAttribute :: PslModel.Body -> Maybe PslModel.Attribute
|
||||
findIdBlockAttribute (PslModel.Body elements) = find isIdBlockAttribute attributes
|
||||
where
|
||||
attributes = [attr | (PslModel.ElementBlockAttribute attr) <- elements]
|
||||
|
||||
isIdBlockAttribute :: PslModel.Attribute -> Bool
|
||||
isIdBlockAttribute PslModel.Attribute {_attrName = attrName} = attrName == idBlockAttributeName
|
||||
|
||||
-- We define the ID block attribute as an attribute with the name @@id.
|
||||
idBlockAttributeName :: String
|
||||
idBlockAttributeName = "id"
|
47
waspc/test/AppSpec/EntityTest.hs
Normal file
47
waspc/test/AppSpec/EntityTest.hs
Normal file
@ -0,0 +1,47 @@
|
||||
module AppSpec.EntityTest where
|
||||
|
||||
import Test.Tasty.Hspec
|
||||
import Wasp.AppSpec.Entity (getIdField)
|
||||
import qualified Wasp.AppSpec.Entity as Entity
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
|
||||
spec_AppSpecEntityTest :: Spec
|
||||
spec_AppSpecEntityTest = do
|
||||
describe "getIdField" $ do
|
||||
it "gets primary field from entity when it exists" $ do
|
||||
getIdField entityWithIdField `shouldBe` Just idField
|
||||
it "returns Nothing if primary field doesn't exist" $ do
|
||||
getIdField entityWithoutIdField `shouldBe` Nothing
|
||||
where
|
||||
entityWithIdField =
|
||||
Entity.makeEntity $
|
||||
PslModel.Body
|
||||
[ PslModel.ElementField idField,
|
||||
PslModel.ElementField someOtherField
|
||||
]
|
||||
entityWithoutIdField =
|
||||
Entity.makeEntity $
|
||||
PslModel.Body
|
||||
[ PslModel.ElementField someOtherField
|
||||
]
|
||||
|
||||
idField =
|
||||
PslModel.Field
|
||||
{ PslModel._name = "id",
|
||||
PslModel._type = PslModel.Int,
|
||||
PslModel._attrs =
|
||||
[ PslModel.Attribute
|
||||
{ PslModel._attrName = "id",
|
||||
PslModel._attrArgs = []
|
||||
}
|
||||
],
|
||||
PslModel._typeModifiers = []
|
||||
}
|
||||
|
||||
someOtherField =
|
||||
PslModel.Field
|
||||
{ PslModel._name = "description",
|
||||
PslModel._type = PslModel.String,
|
||||
PslModel._attrs = [],
|
||||
PslModel._typeModifiers = []
|
||||
}
|
168
waspc/test/Generator/CrudTest.hs
Normal file
168
waspc/test/Generator/CrudTest.hs
Normal file
@ -0,0 +1,168 @@
|
||||
{-# LANGUAGE PartialTypeSignatures #-}
|
||||
|
||||
module Generator.CrudTest where
|
||||
|
||||
import Data.Aeson (KeyValue ((.=)), Value, object)
|
||||
import Data.Aeson.Types (Pair)
|
||||
import StrongPath (relfileP)
|
||||
import qualified StrongPath as SP
|
||||
import Test.Tasty.Hspec
|
||||
import qualified Wasp.AppSpec.Core.Ref as AS.Core.Ref
|
||||
import qualified Wasp.AppSpec.Crud as AS.Crud
|
||||
import qualified Wasp.AppSpec.ExtImport as AS.ExtImport
|
||||
import Wasp.Generator.Crud (getCrudOperationJson)
|
||||
import Wasp.Psl.Ast.Model (Field (_typeModifiers))
|
||||
import qualified Wasp.Psl.Ast.Model as PslModel
|
||||
|
||||
spec_GeneratorCrudTest :: Spec
|
||||
spec_GeneratorCrudTest = do
|
||||
describe "getCrudOperationJson" $ do
|
||||
it "returns empty operations list when no operations are defined" $ do
|
||||
getCrudOperationJson
|
||||
crudOperationsName
|
||||
crudWithoutOperations
|
||||
primaryEntityField
|
||||
`shouldBe` mkOperationsJson []
|
||||
|
||||
it "adds JSON for defined operations" $ do
|
||||
getCrudOperationJson
|
||||
crudOperationsName
|
||||
crudWithoutOperations
|
||||
{ AS.Crud.operations =
|
||||
AS.Crud.CrudOperations
|
||||
{ get = defaultCrudOperationOptions,
|
||||
getAll = defaultCrudOperationOptions,
|
||||
create = defaultCrudOperationOptions,
|
||||
update = Nothing,
|
||||
delete = Nothing
|
||||
}
|
||||
}
|
||||
primaryEntityField
|
||||
`shouldBe` mkOperationsJson
|
||||
[ "Get" .= mkOperationJson "get" "tasks/get" NotPublic,
|
||||
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
|
||||
"Create" .= mkOperationJson "create" "tasks/create" NotPublic
|
||||
]
|
||||
|
||||
it "returns proper JSON for public operations" $ do
|
||||
getCrudOperationJson
|
||||
crudOperationsName
|
||||
AS.Crud.Crud
|
||||
{ entity = AS.Core.Ref.Ref crudOperationEntityName,
|
||||
operations =
|
||||
AS.Crud.CrudOperations
|
||||
{ get = publicCrudOperationOptions,
|
||||
getAll = privateCrudOperationOptions,
|
||||
create = publicCrudOperationOptions,
|
||||
update = Nothing,
|
||||
delete = Nothing
|
||||
}
|
||||
}
|
||||
primaryEntityField
|
||||
`shouldBe` mkOperationsJson
|
||||
[ "Get" .= mkOperationJson "get" "tasks/get" Public,
|
||||
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
|
||||
"Create" .= mkOperationJson "create" "tasks/create" Public
|
||||
]
|
||||
|
||||
it "allows overrides of operations" $ do
|
||||
getCrudOperationJson
|
||||
crudOperationsName
|
||||
crudWithoutOperations
|
||||
{ AS.Crud.operations =
|
||||
AS.Crud.CrudOperations
|
||||
{ get =
|
||||
Just
|
||||
( AS.Crud.CrudOperationOptions
|
||||
{ isPublic = Just True,
|
||||
overrideFn =
|
||||
Just $
|
||||
AS.ExtImport.ExtImport
|
||||
{ AS.ExtImport.name = AS.ExtImport.ExtImportField "getTask",
|
||||
AS.ExtImport.path = SP.castRel [relfileP|bla/tasks.js|]
|
||||
}
|
||||
}
|
||||
),
|
||||
getAll = privateCrudOperationOptions,
|
||||
create = publicCrudOperationOptions,
|
||||
update = Nothing,
|
||||
delete = Nothing
|
||||
}
|
||||
}
|
||||
primaryEntityField
|
||||
`shouldBe` mkOperationsJson
|
||||
[ "Get" .= mkOperationJson "get" "tasks/get" Public,
|
||||
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
|
||||
"Create" .= mkOperationJson "create" "tasks/create" Public
|
||||
]
|
||||
where
|
||||
crudOperationsName = "tasks"
|
||||
crudOperationEntityName = "Task"
|
||||
primaryEntityField =
|
||||
PslModel.Field
|
||||
{ PslModel._name = "id",
|
||||
PslModel._type = PslModel.Int,
|
||||
PslModel._attrs =
|
||||
[ PslModel.Attribute
|
||||
{ PslModel._attrName = "id",
|
||||
PslModel._attrArgs = []
|
||||
}
|
||||
],
|
||||
PslModel._typeModifiers = []
|
||||
}
|
||||
crudWithoutOperations =
|
||||
AS.Crud.Crud
|
||||
{ entity = AS.Core.Ref.Ref crudOperationEntityName,
|
||||
operations =
|
||||
AS.Crud.CrudOperations
|
||||
{ get = Nothing,
|
||||
getAll = Nothing,
|
||||
create = Nothing,
|
||||
update = Nothing,
|
||||
delete = Nothing
|
||||
}
|
||||
}
|
||||
defaultCrudOperationOptions =
|
||||
Just
|
||||
( AS.Crud.CrudOperationOptions
|
||||
{ isPublic = Nothing,
|
||||
overrideFn = Nothing
|
||||
}
|
||||
)
|
||||
publicCrudOperationOptions =
|
||||
Just
|
||||
( AS.Crud.CrudOperationOptions
|
||||
{ isPublic = Just True,
|
||||
overrideFn = Nothing
|
||||
}
|
||||
)
|
||||
privateCrudOperationOptions =
|
||||
Just
|
||||
( AS.Crud.CrudOperationOptions
|
||||
{ isPublic = Just False,
|
||||
overrideFn = Nothing
|
||||
}
|
||||
)
|
||||
|
||||
mkOperationsJson :: [Pair] -> Value
|
||||
mkOperationsJson operations =
|
||||
object
|
||||
[ "name" .= crudOperationsName,
|
||||
"operations" .= object operations,
|
||||
"entitiesArray" .= ("['Task']" :: String),
|
||||
"idFieldName" .= ("id" :: String),
|
||||
"entityLower" .= ("task" :: String),
|
||||
"entityUpper" .= ("Task" :: String)
|
||||
]
|
||||
|
||||
mkOperationJson :: String -> String -> IsOperationPublic -> Value
|
||||
mkOperationJson route fullPath isPublic =
|
||||
object
|
||||
[ "route" .= route,
|
||||
"fullPath" .= fullPath,
|
||||
"isPublic" .= case isPublic of
|
||||
Public -> True
|
||||
NotPublic -> False
|
||||
]
|
||||
|
||||
data IsOperationPublic = Public | NotPublic
|
@ -187,6 +187,7 @@ library
|
||||
Wasp.AppSpec.Action
|
||||
Wasp.AppSpec.Api
|
||||
Wasp.AppSpec.ApiNamespace
|
||||
Wasp.AppSpec.Crud
|
||||
Wasp.AppSpec.App
|
||||
Wasp.AppSpec.App.Auth
|
||||
Wasp.AppSpec.App.Auth.PasswordReset
|
||||
@ -252,6 +253,8 @@ library
|
||||
Wasp.Generator.AuthProviders.OAuth
|
||||
Wasp.Generator.AuthProviders.Local
|
||||
Wasp.Generator.AuthProviders.Email
|
||||
Wasp.Generator.Crud
|
||||
Wasp.Generator.Crud.Routes
|
||||
Wasp.Generator.ServerGenerator
|
||||
Wasp.Generator.ServerGenerator.JsImport
|
||||
Wasp.Generator.ServerGenerator.ApiRoutesG
|
||||
@ -270,6 +273,7 @@ library
|
||||
Wasp.Generator.ServerGenerator.OperationsRoutesG
|
||||
Wasp.Generator.ServerGenerator.Setup
|
||||
Wasp.Generator.ServerGenerator.Start
|
||||
Wasp.Generator.ServerGenerator.CrudG
|
||||
Wasp.Generator.Setup
|
||||
Wasp.Generator.Start
|
||||
Wasp.Generator.Templates
|
||||
@ -290,6 +294,7 @@ library
|
||||
Wasp.Generator.WebAppGenerator.Setup
|
||||
Wasp.Generator.WebAppGenerator.Start
|
||||
Wasp.Generator.WebAppGenerator.Test
|
||||
Wasp.Generator.WebAppGenerator.CrudG
|
||||
Wasp.Generator.WriteFileDrafts
|
||||
Wasp.Node.Version
|
||||
Wasp.Project
|
||||
@ -305,6 +310,7 @@ library
|
||||
Wasp.Psl.Ast.Model
|
||||
Wasp.Psl.Generator.Model
|
||||
Wasp.Psl.Parser.Model
|
||||
Wasp.Psl.Util
|
||||
Wasp.SemanticVersion
|
||||
Wasp.Util
|
||||
Wasp.Util.Network.Socket
|
||||
@ -479,6 +485,7 @@ test-suite waspc-test
|
||||
Analyzer.TypeCheckerTest
|
||||
AnalyzerTest
|
||||
AppSpec.ValidTest
|
||||
AppSpec.EntityTest
|
||||
ErrorTest
|
||||
FilePath.ExtraTest
|
||||
Fixtures
|
||||
@ -491,6 +498,7 @@ test-suite waspc-test
|
||||
Generator.WebAppGeneratorTest
|
||||
Generator.WriteFileDraftsTest
|
||||
Generator.JsImportTest
|
||||
Generator.CrudTest
|
||||
Generator.CommonTest
|
||||
Psl.Common.ModelTest
|
||||
Psl.Generator.ModelTest
|
||||
|
239
web/docs/guides/crud.md
Normal file
239
web/docs/guides/crud.md
Normal file
@ -0,0 +1,239 @@
|
||||
---
|
||||
title: Automatic CRUD
|
||||
---
|
||||
|
||||
import ImgWithCaption from '../../blog/components/ImgWithCaption'
|
||||
|
||||
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
|
||||
|
||||
## Defining new CRUD operations
|
||||
|
||||
Let's say we have a `Task` entity. We want to have `getAll` and `get` queries for it, and also `create` and `update` actions. We do this by creating new `crud` declaration in Wasp, named `Tasks`, that uses default implementation for `getAll`, `get` and `update`, while specifying a custom implementation for `create`. We also configured `getAll` to be publicly available (no auth needed).
|
||||
|
||||
```wasp title="main.wasp"
|
||||
crud Tasks { // crud name here is "Tasks"
|
||||
entity: Task,
|
||||
operations: {
|
||||
getAll: {
|
||||
isPublic: true, // optional, defaults to false
|
||||
},
|
||||
get: {},
|
||||
create: {
|
||||
overrideFn: import { createTask } from "@server/tasks.js", // optional
|
||||
},
|
||||
update: {},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Result of this is that the queries and actions we just specified are now available in our Wasp app!
|
||||
|
||||
## Example: simple tasks app
|
||||
|
||||
We'll see an example app with auth and CRUD operations for some `Task` entity.
|
||||
|
||||
<ImgWithCaption alt="Automatic CRUD with Wasp" source="img/crud-guide.gif" caption="We are building a simple tasks app with username based auth"/>
|
||||
|
||||
### Creating the app
|
||||
|
||||
We can start by running `wasp new tasksCrudApp` and then we'll add the following to our `main.wasp` file:
|
||||
|
||||
```wasp title="main.wasp"
|
||||
app tasksCrudApp {
|
||||
wasp: {
|
||||
version: "^0.11.0"
|
||||
},
|
||||
title: "Tasks Crud App",
|
||||
|
||||
// We enabled auth and set the auth method to username and password
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
usernameAndPassword: {},
|
||||
},
|
||||
onAuthFailedRedirectTo: "/login",
|
||||
},
|
||||
}
|
||||
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
tasks Task[]
|
||||
psl=}
|
||||
|
||||
// We defined a Task entity on which we'll enable CRUD later on
|
||||
entity Task {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
description String
|
||||
isDone Boolean
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
psl=}
|
||||
|
||||
// Tasks app routes
|
||||
route RootRoute { path: "/", to: MainPage }
|
||||
page MainPage {
|
||||
component: import Main from "@client/MainPage.tsx",
|
||||
authRequired: true,
|
||||
}
|
||||
|
||||
route LoginRoute { path: "/login", to: LoginPage }
|
||||
page LoginPage {
|
||||
component: import { LoginPage } from "@client/LoginPage.tsx",
|
||||
}
|
||||
|
||||
route SignupRoute { path: "/signup", to: SignupPage }
|
||||
page SignupPage {
|
||||
component: import { SignupPage } from "@client/SignupPage.tsx",
|
||||
}
|
||||
```
|
||||
|
||||
We can then run `wasp db migrate-dev` to create the database and run the migrations.
|
||||
|
||||
### Adding CRUD to the `Task` entity ✨
|
||||
|
||||
We add the following to our Wasp file to enable automatic CRUD for `Task`:
|
||||
|
||||
```wasp title="main.wasp"
|
||||
// ...
|
||||
|
||||
crud Tasks {
|
||||
entity: Task,
|
||||
operations: {
|
||||
getAll: {},
|
||||
create: {
|
||||
overrideFn: import { createTask } from "@server/tasks.js",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
You'll notice that we enabled only `getAll` and `create` operations. This means that only these operations will be available. We also overrode the `create` operation with a custom implementation. This means that the `create` operation will not be generated, but instead, the `createTask` function from `@server/tasks.js` will be used.
|
||||
|
||||
### Implementing the `create` operation
|
||||
|
||||
We have the following implementation in `src/server/tasks.js`:
|
||||
|
||||
```ts title="src/server/tasks.ts"
|
||||
import type { CreateAction } from '@wasp/crud/Tasks'
|
||||
import type { Task } from '@wasp/entities'
|
||||
import HttpError from '@wasp/core/HttpError.js';
|
||||
|
||||
export const createTask: CreateAction<
|
||||
{ description: string; isDone: boolean },
|
||||
Task,
|
||||
> = async (args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'User not authenticated.')
|
||||
}
|
||||
|
||||
const { description, isDone } = args
|
||||
const { Task } = context.entities
|
||||
|
||||
return await Task.create({
|
||||
data: {
|
||||
description,
|
||||
isDone,
|
||||
// Connect the task to the user that is creating it
|
||||
user: {
|
||||
connect: {
|
||||
id: context.user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
We made a custom `create` operation because we want to make sure that the task is connected to the user that is creating it. By default, the `create` operation would not do that. Read more about the [default implementations](/docs/language/features#default-crud-operations-implementations).
|
||||
|
||||
### Using the generated CRUD operations
|
||||
|
||||
And let's use the generated operations in our client code:
|
||||
|
||||
```jsx title="pages/MainPage.jsx"
|
||||
import { Tasks } from "@wasp/crud/Tasks";
|
||||
import { useState } from "react";
|
||||
// Default CSS that comes with Wasp for the main page
|
||||
import "./Main.css";
|
||||
|
||||
export const MainPage = () => {
|
||||
const { data: tasks, isLoading, error } = Tasks.getAll.useQuery();
|
||||
const createAction = Tasks.create.useAction();
|
||||
const [taskDescription, setTaskDescription] = useState("");
|
||||
|
||||
function handleCreateTask() {
|
||||
createAction({ description: taskDescription, isDone: false });
|
||||
setTaskDescription("");
|
||||
}
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<div>
|
||||
<input
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleCreateTask}>Create task</button>
|
||||
</div>
|
||||
<ul>
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id}>{task.description}</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
And here are the login and signup pages:
|
||||
|
||||
```jsx title="src/client/LoginPage.jsx"
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Login</h1>
|
||||
<LoginForm />
|
||||
<div>
|
||||
<Link to="/signup">Create an account</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```jsx title="src/client/SignupPage.jsx"
|
||||
import { SignupForm } from '@wasp/auth/forms/Signup'
|
||||
|
||||
export function SignupPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Signup</h1>
|
||||
<SignupForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
That's it. You can now run `wasp start` and see the app in action.
|
||||
|
||||
You should see a login page and a signup page. After you log in, you should see a page with a list of tasks and a form to create new tasks.
|
||||
|
||||
### Future of CRUD operations in Wasp
|
||||
|
||||
CRUD operations currently have a limited set of knowledge about the business logic they are implementing. For example, they don't know that a task should be connected to the user that is creating it. This is why we had to override the `create` operation in the example above.
|
||||
|
||||
Another thing, they are not aware of the authorization rules. For example, they don't know that a user should not be able to create a task for another user. In the future, we will be adding role-based authorization to Wasp, and we plan to make CRUD operations aware of the authorization rules.
|
||||
|
||||
Another issue is input validation and sanitization. For example, we might want to make sure that the task description is not empty.
|
||||
|
||||
To conclude, CRUD operations are a mechanism for getting a backend up and running quickly, but it depends on the information it can get from the Wasp app. The more information that it can pick up from your app, the more powerful it will be out of the box. We plan on supporting CRUD operations and growing them to become the easiest way to create your backend.
|
@ -602,13 +602,178 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
### CRUD operations on top of entities
|
||||
|
||||
:::caution Early preview
|
||||
This feature is currently in early preview. It doesn't contain all the planned features.
|
||||
|
||||
In the future iterations of Wasp we plan on supporting:
|
||||
- **authorization** that will allow you to specify which users can perform which operations
|
||||
- **validation** of input data (e.g. using Zod schema validation)
|
||||
:::
|
||||
|
||||
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
|
||||
|
||||
#### Which operations are supported?
|
||||
|
||||
If we create CRUD operations for an entity named `Task`,
|
||||
|
||||
```wasp title="main.wasp"
|
||||
crud Tasks { // crud name here is "Tasks"
|
||||
entity: Task,
|
||||
operations: {
|
||||
getAll: {
|
||||
isPublic: true, // optional, defaults to false
|
||||
},
|
||||
get: {},
|
||||
create: {
|
||||
overrideFn: import { createTask } from "@server/tasks.js", // optional
|
||||
},
|
||||
update: {},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Wasp will give you the following default implementations:
|
||||
|
||||
**getAll** - returns all entities
|
||||
|
||||
```js
|
||||
// ...
|
||||
|
||||
// If the operation is not public, Wasp checks if an authenticated user
|
||||
// is making the request.
|
||||
|
||||
return Task.findMany()
|
||||
```
|
||||
|
||||
**get** - returns one entity by id field
|
||||
|
||||
```js
|
||||
// ...
|
||||
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
|
||||
return Task.findUnique({ where: { id: args.id } })
|
||||
```
|
||||
|
||||
**create** - creates a new entity
|
||||
|
||||
```js
|
||||
// ...
|
||||
return Task.create({ data: args.data })
|
||||
```
|
||||
|
||||
**update** - updates an existing entity
|
||||
|
||||
```js
|
||||
// ...
|
||||
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
|
||||
return Task.update({ where: { id: args.id }, data: args.data })
|
||||
```
|
||||
|
||||
**delete** - deletes an existing entity
|
||||
|
||||
```js
|
||||
// ...
|
||||
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
|
||||
return Task.delete({ where: { id: args.id } })
|
||||
```
|
||||
|
||||
:::info Current Limitations
|
||||
In the default `create` and `update` implementations, we are saving all of the data that the client sends to the server. This is not always desirable, i.e. in the case when the client should not be able to modify all of the data in the entity.
|
||||
|
||||
[In the future](#/docs/guides/crud#future-of-crud-operations-in-wasp), we are planning to add validation of action input, where only the data that the user is allowed to change will be saved.
|
||||
|
||||
For now, the solution is to provide an override function. You can override the default implementation by using the `overrideFn` option and implementing the validation logic yourself.
|
||||
|
||||
:::
|
||||
|
||||
#### CRUD declaration
|
||||
|
||||
The CRUD declaration works on top of an existing entity declaration. It is declared as follows:
|
||||
|
||||
```wasp title="main.wasp"
|
||||
crud Tasks { // crud name here is "Tasks"
|
||||
entity: Task,
|
||||
operations: {
|
||||
getAll: {
|
||||
isPublic: true, // optional, defaults to false
|
||||
},
|
||||
get: {},
|
||||
create: {
|
||||
overrideFn: import { createTask } from "@server/tasks.js", // optional
|
||||
},
|
||||
update: {},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
It has the following fields:
|
||||
- `entity: Entity` - the entity to which the CRUD operations will be applied.
|
||||
- `operations: { [operationName]: CrudOperationOptions }` - the operations to be generated. The key is the name of the operation, and the value is the operation configuration.
|
||||
- The possible values for `operationName` are:
|
||||
- `getAll`
|
||||
- `get`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `CrudOperationOptions` can have the following fields:
|
||||
- `isPublic: bool` - Whether the operation is public or not. If it is public, no auth is required to access it. If it is not public, it will be available only to authenticated users. Defaults to `false`.
|
||||
- `overrideFn: ServerImport` - The import statement of the optional override implementation in Node.js.
|
||||
|
||||
#### Defining the overrides
|
||||
|
||||
Like with actions and queries, you can define the implementation in a Javascript/Typescript file. The overrides are functions that take the following arguments:
|
||||
- `args` - The arguments of the operation i.e. the data that's sent from the client.
|
||||
- `context` - Context contains the `user` making the request and the `entities` object containing the entity that's being operated on.
|
||||
|
||||
You can also import types for each of the functions you want to override from `@wasp/crud/{crud name}`. The available types are:
|
||||
- `GetAllQuery`
|
||||
- `GetQuery`
|
||||
- `CreateAction`
|
||||
- `UpdateAction`
|
||||
- `DeleteAction`
|
||||
|
||||
If you have a CRUD named `Tasks`, you would import the types like this:
|
||||
```ts
|
||||
import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks'
|
||||
|
||||
// Each of the types is a generic type, so you can use it like this:
|
||||
export const getAllOverride: GetAllQuery<Input, Output> = async (args, context) => {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
We are showing an example of an override in the [CRUD guide](/docs/guides/crud).
|
||||
|
||||
#### Using the CRUD operations in client code
|
||||
|
||||
On the client, you import the CRUD operations from `@wasp/crud/{crud name}`. The names of the imports are the same as the names of the operations. For example, if you have a CRUD called `Tasks`, you would import the operations like this:
|
||||
|
||||
```jsx title="SomePage.jsx"
|
||||
import { Tasks } from '@wasp/crud/Tasks'
|
||||
```
|
||||
|
||||
You can then access the operations like this:
|
||||
```jsx title="SomePage.jsx"
|
||||
const { data } = Tasks.getAll.useQuery()
|
||||
const { data } = Tasks.get.useQuery({ id: 1 })
|
||||
const createAction = Tasks.create.useAction()
|
||||
const updateAction = Tasks.update.useAction()
|
||||
const deleteAction = Tasks.delete.useAction()
|
||||
|
||||
// The CRUD operations are using the existing actions and queries
|
||||
// under the hood, so all the options are available as before.
|
||||
```
|
||||
|
||||
Check out the [CRUD guide](/docs/guides/crud) to see how to use the CRUD operations in client code.
|
||||
|
||||
## APIs
|
||||
|
||||
In Wasp, the default client-server interaction mechanism is through [Operations](#queries-and-actions-aka-operations). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`! Best of all, they should look and feel very familiar.
|
||||
|
||||
### API
|
||||
|
||||
APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations, and have no client-side helpers (like `useQuery`).
|
||||
APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations and have no client-side helpers (like `useQuery`).
|
||||
|
||||
To create a Wasp API, you must:
|
||||
1. Define the APIs NodeJS implementation
|
||||
@ -731,7 +896,7 @@ export const fooBar : FooBar = (req, res, context) => {
|
||||
|
||||
The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud).
|
||||
|
||||
### `apiNamespace`
|
||||
### apiNamespace
|
||||
|
||||
An `apiNamespace` is a simple declaration used to apply some `middlewareConfigFn` to all APIs under some specific path. For example:
|
||||
|
||||
|
@ -74,9 +74,11 @@ While fundamental types are here to be basic building blocks of a language, and
|
||||
- **page**
|
||||
- **query**
|
||||
- **route**
|
||||
- **crud**
|
||||
- Enum types
|
||||
- **DbSystem**
|
||||
- **HttpMethod**
|
||||
- **JobExecutor**
|
||||
- **EmailProvider**
|
||||
|
||||
For more details about each of the domain types, both regarding their body types and what they mean, check the [Features](/language/features.md) section.
|
||||
|
@ -390,3 +390,32 @@ and use it to type your seed function like this:
|
||||
```ts
|
||||
export const devSeedSimple: DbSeedFn = async (prismaClient) => { ... }
|
||||
```
|
||||
|
||||
## CRUD operations on entities
|
||||
|
||||
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
|
||||
|
||||
Read more about CRUD operations in Wasp [here](/docs/language/features#crud-operations).
|
||||
|
||||
### Using types for CRUD operations overrides
|
||||
|
||||
If you writing the override implementation in Typescript, you'll have access to generated types. The overrides are functions that take the following arguments:
|
||||
- `args` - The arguments of the operation i.e. the data that's sent from the client.
|
||||
- `context` - Context containing the `user` making the request and the `entities` object containing the entity that's being operated on.
|
||||
|
||||
You can types for each of the functions you want to override from `@wasp/crud/{crud name}`. The types that are available are:
|
||||
- `GetAllQuery`
|
||||
- `GetQuery`
|
||||
- `CreateAction`
|
||||
- `UpdateAction`
|
||||
- `DeleteAction`
|
||||
|
||||
If you have a CRUD named `Tasks`, you would import the types like this:
|
||||
```ts
|
||||
import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks'
|
||||
|
||||
// Each of the types is a generic type, so you can use it like this:
|
||||
export const getAllOverride: GetAllQuery<Input, Output> = async (args, context) => {
|
||||
// ...
|
||||
}
|
||||
```
|
@ -62,6 +62,7 @@ module.exports = {
|
||||
"guides/testing",
|
||||
"guides/sending-emails",
|
||||
"guides/middleware-customization",
|
||||
"guides/crud",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -57,7 +57,7 @@ module.exports = (Prism) => {
|
||||
alias: "plain",
|
||||
},
|
||||
"declaration-type": {
|
||||
pattern: /\b(action|apiNamespace|api|app|entity|job|page|query|route)\b/,
|
||||
pattern: /\b(action|apiNamespace|api|app|entity|job|page|query|route|crud)\b/,
|
||||
alias: "keyword",
|
||||
},
|
||||
"class-name": {
|
||||
|
BIN
web/static/img/crud-guide.gif
Normal file
BIN
web/static/img/crud-guide.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 216 KiB |
Loading…
Reference in New Issue
Block a user