diff --git a/.gitignore b/.gitignore
index 0ad45fa27..015bcdcdd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
# macOS related
.DS_Store
+.vscode/
diff --git a/README.md b/README.md
index d93d87c2a..2f95e541b 100644
--- a/README.md
+++ b/README.md
@@ -2,67 +2,77 @@
- A programming language that understands what a web app is.
+ The fastest way to develop full-stack web apps with React & Node.js.
-
-
+
------
- {= We are hiring! =}
+ Web page | Docs
-- [**Project page**](https://wasp-lang.dev)
-- [**Docs**](https://wasp-lang.dev/docs)
-
-Wasp (**W**eb **A**pplication **Sp**ecification Language) is a declarative DSL (domain-specific language) for developing, building and deploying modern full-stack web apps with less code.
+Wasp (**W**eb **A**pplication **Sp**ecification) is a Rails-like framework for React, Node.js and Prisma.
+Build your app in a day and deploy it with a single CLI command!
-Concepts such as *app*, *page*, *user*, *login*, *frontend*, *production*, etc. are baked into the language, bringing a new level of expressiveness and allowing you to get more work done with fewer lines of code.
+### Why is Wasp awesome
+- ๐ **Quick start**: Due to its expressiveness, you can create and deploy a production-ready web app from scratch with very few lines of concise, consistent, declarative code.
+- ๐ **No boilerplate**: By abstracting away complex full-stack features, there is less boilerplate code. That means less code to maintain and understand! It also means easier upgrades.
+- ๐ **No lock-in**: You can deploy Wasp app anywhere you like. There is no lock-in into specific providers, you have full control over the code (and can actually check it out in .wasp/ dir if you are interested ).
-While describing high-level features with Wasp, you still write the rest of your logic in your favorite technologies (currently React, NodeJS, Prisma).
+### Features
+ ๐ Full-stack Auth, ๐๏ธ RPC (Client <-> Server), ๐ Simple Deployment, โ ๏ธJobs, โ๏ธ Email Sending, ๐ Full-stack Type Safety, ...
+### Code example
+Simple Wasp config file in which you describe the high-level details of your web app:
```js
// file: main.wasp
-app TodoApp {
- title: "Todo App",
- wasp: { version: "^0.7.0" }
+app todoApp {
+ title: "ToDo App", // visible in the browser tab
+ wasp: { version: "^0.10.0" },
+ auth: { // full-stack auth out-of-the-box
+ userEntity: User, methods: { email: {...} }
+ }
}
route RootRoute { path: "/", to: MainPage }
page MainPage {
- component: import Main from "@ext/pages/Main.jsx" // Importing React component.
+ authRequired: true, // Limit access to logged in users.
+ component: import Main from "@client/Main.tsx" // Your React code.
}
query getTasks {
- fn: import { getTasks } from "@ext/queries.js", // Importing NodeJS code.
- entities: [Task]
+ fn: import { getTasks } from "@server/tasks.js", // Your Node.js code.
+ entities: [Task] // Automatic cache invalidation.
}
-entity Task {=psl
+entity Task {=psl // Your Prisma data model.
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
psl=}
```
-Source files (`.wasp`, `.js(x)`, `.css`, ...) are compiled by `waspc` (Wasp compiler) into the web technology stack of your choice (currently React + NodeJS/ExpressJS + Prisma).
+The rest of the code you write in React / Node.js / Prisma and just reference it from the .wasp file.
:arrow_forward: Check out [TodoApp example](/examples/tutorials/TodoApp) for the complete code example. :arrow_backward:
-Why is Wasp awesome:
-- **Quick start**: Due to its expressiveness, you can create and deploy a production-ready web app from scratch with very few lines of concise, consistent, declarative code.
-- **Flexible**: When you need more control than Wasp offers, you can write code in existing technologies such as js/html/css/... and combine it with Wasp code!
-- **No lock-in**: If Wasp becomes too limiting for you, simply eject and continue with the generated source code, which is human-readable.
+### How it works
+
+
+
+Given a simple .wasp configuration file that describes the high-level details of your web app, and .js(x)/.css/..., source files with your unique logic, Wasp compiler generates the full source of your web app in the target stack: front-end, back-end and deployment.
+
+This unique approach is what makes Wasp "smart" and gives it its super powers!
For more information about Wasp, check [**docs**](https://wasp-lang.dev/docs).
@@ -72,9 +82,9 @@ Run
```
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
```
-to install Wasp on OSX/Linux. From there, just follow the instructions to run your first app in less than a minute!
+to install Wasp on OSX/Linux/WSL(Win). From there, just follow the instructions to run your first app in less than a minute!
-For more details (including installing on Windows) check out [the docs](https://wasp-lang.dev/docs).
+For more details check out [the docs](https://wasp-lang.dev/docs).
# This repository
@@ -86,7 +96,7 @@ Currently, Wasp is in beta, with most features flushed out and working well.
However, there are still a lot of improvements and additions that we have in mind for the future, and we are working on them constantly, so you can expect a lot of changes and improvements in the future.
As Wasp grows further, it should allow the development of web apps of increasing complexity!
-While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma. We might yet change that as time goes on, taking trends into account, but for now, this is serving us well to develop compiler and language.
+While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma. We might yet change that as time goes on, taking trends into account, but for now, this is serving us well to develop Wasp.
# Contributing
@@ -94,6 +104,8 @@ Any way you want to contribute is a good way :)!
The best place to start is to check out [waspc/](waspc/), where you can find detailed steps for the first time contributors + technical details about the Wasp compiler.
+Core of Wasp is built in Haskell, but there is also a lot of non-Haskell parts of Wasp, so you will certainly be able to find something for you!
+
Even if you don't plan to submit any code, just joining the discussion on discord [![Discord](https://img.shields.io/discord/686873244791210014?label=chat%20on%20discord)](https://discord.gg/rzdnErX) and giving your feedback is already great and helps a lot (motivates us and helps us figure out how to shape Wasp)!
You can also:
diff --git a/examples/realworld/README.md b/examples/realworld/README.md
index 9410bdd64..d9441ba4a 100644
--- a/examples/realworld/README.md
+++ b/examples/realworld/README.md
@@ -8,15 +8,9 @@ Here, we implement it in Wasp, by following their [specification](https://realwo
This app is deployed at https://wasp-rwa.netlify.app/ .
# Development
-
-### Database
-Wasp needs postgre database running - provide it with database connection URL via env var `DATABASE_URL` - best to do it via .env file.
-
-Easy way to get going with postgresql database: run db with `docker run --rm --publish 5432:5432 -v postgresql-data:/var/lib/postgresql/data --env POSTGRES_PASSWORD=devpass postgres`.
-`DATABASE_URL` in this case is `postgresql://postgres:devpass@localhost:5432/postgres`.
-
### Running
-`wasp start`
+`wasp start` to run the client and the server.
+`wasp start db` to run the dev database.
## TODO
diff --git a/examples/realworld/main.wasp b/examples/realworld/main.wasp
index af48395a9..5e6a100b9 100644
--- a/examples/realworld/main.wasp
+++ b/examples/realworld/main.wasp
@@ -1,6 +1,6 @@
app Conduit {
wasp: {
- version: "^0.8.0"
+ version: "^0.10.0"
},
title: "Conduit",
@@ -17,7 +17,10 @@ app Conduit {
onAuthFailedRedirectTo: "/login"
},
- db: { system: PostgreSQL },
+ db: {
+ system: PostgreSQL,
+ seeds: [ import { devSeedSimple } from "@server/seeds.js" ]
+ },
dependencies: [
("prop-types", "15.7.2"),
@@ -30,8 +33,6 @@ app Conduit {
]
}
-
-
// ----------------- Pages ------------------ //
route RootRoute { path: "/", to: MainPage }
diff --git a/examples/realworld/src/client/MainPage.jsx b/examples/realworld/src/client/MainPage.jsx
index 407afe1bd..01af818b5 100644
--- a/examples/realworld/src/client/MainPage.jsx
+++ b/examples/realworld/src/client/MainPage.jsx
@@ -11,7 +11,7 @@ import Chip from '@material-ui/core/Chip'
import Paper from '@material-ui/core/Paper';
import { makeStyles } from '@material-ui/core/styles'
-import useAuth from '@wasp/auth/useAuth.js'
+import useAuth from '@wasp/auth/useAuth'
import { useQuery } from '@wasp/queries'
import getTags from '@wasp/queries/getTags'
diff --git a/examples/realworld/src/client/Navbar.jsx b/examples/realworld/src/client/Navbar.jsx
index 3a0235e5d..f4cc18c36 100644
--- a/examples/realworld/src/client/Navbar.jsx
+++ b/examples/realworld/src/client/Navbar.jsx
@@ -7,7 +7,7 @@ import Button from '@material-ui/core/Button'
import Typography from '@material-ui/core/Typography'
import { makeStyles } from '@material-ui/core/styles'
-import useAuth from '@wasp/auth/useAuth.js'
+import useAuth from '@wasp/auth/useAuth'
const useStyles = makeStyles((theme) => ({
diff --git a/examples/realworld/src/client/article/components/ArticleViewPage.jsx b/examples/realworld/src/client/article/components/ArticleViewPage.jsx
index 244b7edfb..81313f990 100644
--- a/examples/realworld/src/client/article/components/ArticleViewPage.jsx
+++ b/examples/realworld/src/client/article/components/ArticleViewPage.jsx
@@ -17,7 +17,7 @@ import Chip from '@material-ui/core/Chip'
import Button from '@material-ui/core/Button'
import { makeStyles } from '@material-ui/core/styles'
-import useAuth from '@wasp/auth/useAuth.js'
+import useAuth from '@wasp/auth/useAuth'
import { useQuery } from '@wasp/queries'
import getArticle from '@wasp/queries/getArticle'
diff --git a/examples/realworld/src/client/user/components/UserProfilePage.jsx b/examples/realworld/src/client/user/components/UserProfilePage.jsx
index 60d4287ef..f3de5987a 100644
--- a/examples/realworld/src/client/user/components/UserProfilePage.jsx
+++ b/examples/realworld/src/client/user/components/UserProfilePage.jsx
@@ -9,7 +9,7 @@ import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
import { makeStyles } from '@material-ui/core/styles'
-import useAuth from '@wasp/auth/useAuth.js'
+import useAuth from '@wasp/auth/useAuth'
import { useQuery } from '@wasp/queries'
import getUser from '@wasp/queries/getUser'
diff --git a/examples/realworld/src/client/user/components/UserSettingsPage.jsx b/examples/realworld/src/client/user/components/UserSettingsPage.jsx
index 729f52eb3..d9766db8b 100644
--- a/examples/realworld/src/client/user/components/UserSettingsPage.jsx
+++ b/examples/realworld/src/client/user/components/UserSettingsPage.jsx
@@ -7,7 +7,7 @@ import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { makeStyles } from '@material-ui/core/styles'
-import logout from '@wasp/auth/logout.js'
+import logout from '@wasp/auth/logout'
import updateUser from '@wasp/actions/updateUser'
diff --git a/examples/realworld/src/server/seeds.js b/examples/realworld/src/server/seeds.js
new file mode 100644
index 000000000..97ffcdc22
--- /dev/null
+++ b/examples/realworld/src/server/seeds.js
@@ -0,0 +1,29 @@
+import { createArticle } from './article/actions.js'
+
+export const devSeedSimple = async (prismaClient) => {
+ const user = await createUser(prismaClient, {
+ username: "RiuTheDog",
+ password: "bark1234",
+ email: "riu@wasp-lang.dev"
+ })
+
+ await createArticle(
+ {
+ title: "The Art of Barking",
+ description: "Elevate your barking to another level by following advice of a master barkerer Riu",
+ markdownContent: (
+ "# Barking - what is it really?"
+ + "\n\nThe fact that wolves don't bark but dogs do already tells us a lot about barking: "
+ + "it can't be studied without also studying humans and their influence on dogs."
+ + "\n\nTODO: bark bark bark"
+ ),
+ tags: [{ name: 'barking' }, { name: 'dogs' }]
+ },
+ { user, entities: { Article: prismaClient.article } }
+ )
+}
+
+async function createUser (prismaClient, data) {
+ const { password, ...newUser } = await prismaClient.user.create({ data })
+ return newUser
+}
\ No newline at end of file
diff --git a/examples/thoughts/main.wasp b/examples/thoughts/main.wasp
index b0a31356a..59d8758e6 100644
--- a/examples/thoughts/main.wasp
+++ b/examples/thoughts/main.wasp
@@ -1,20 +1,21 @@
app Thoughts {
wasp: {
- version: "^0.8.0"
+ version: "^0.10.0"
},
title: "Thoughts",
- db: { system: PostgreSQL },
+ db: {
+ system: PostgreSQL,
+ seeds: [ import { devSeedBasic } from "@server/seeds.js" ]
+ },
auth: {
userEntity: User,
- methods: {
- usernameAndPassword: {}
- },
+ methods: { usernameAndPassword: {} },
onAuthFailedRedirectTo: "/login"
},
dependencies: [
("react-markdown", "6.0.1"),
("color-hash", "2.0.1")
- ]
+ ],
}
route MainRoute { path: "/", to: MainPage }
diff --git a/examples/thoughts/src/client/LoginPage.jsx b/examples/thoughts/src/client/LoginPage.jsx
index 36538d3a4..35e47ce24 100644
--- a/examples/thoughts/src/client/LoginPage.jsx
+++ b/examples/thoughts/src/client/LoginPage.jsx
@@ -1,17 +1,19 @@
import React from 'react'
import { Link } from 'react-router-dom'
-import LoginForm from '@wasp/auth/forms/Login'
+import { LoginForm } from '@wasp/auth/forms/Login'
import addWaspSourceHeader from './addWaspSourceHeader'
const LoginPage = (props) => {
return (
<>
-
-
-
- I don't have an account yet ( go to signup).
-
+
+
+
+
+ I don't have an account yet ( go to signup).
+
+
>
)
}
diff --git a/examples/thoughts/src/client/SignupPage.jsx b/examples/thoughts/src/client/SignupPage.jsx
index a8ebc5479..a6c47ad40 100644
--- a/examples/thoughts/src/client/SignupPage.jsx
+++ b/examples/thoughts/src/client/SignupPage.jsx
@@ -1,17 +1,19 @@
import React from 'react'
import { Link } from 'react-router-dom'
-import SignupForm from '@wasp/auth/forms/Signup'
+import { SignupForm } from '@wasp/auth/forms/Signup'
import addWaspSourceHeader from './addWaspSourceHeader'
const SignupPage = (props) => {
return (
<>
-
-
-
- I already have an account ( go to login).
-
+
+
+
+
+ I already have an account ( go to login).
+
+
>
)
}
diff --git a/examples/thoughts/src/server/seeds.js b/examples/thoughts/src/server/seeds.js
new file mode 100644
index 000000000..a2a0c5410
--- /dev/null
+++ b/examples/thoughts/src/server/seeds.js
@@ -0,0 +1,18 @@
+import { createThought } from './actions.js'
+
+export const devSeedBasic = async (prismaClient) => {
+ const user = await createUser(prismaClient, {
+ username: "RiuTheDog",
+ password: "bark1234"
+ })
+
+ await createThought(
+ { tagNames: ["animals.cats"], textMarkdown: "## My favorite cats\n - Kira\n - Garfield" },
+ { user, entities: { Thought: prismaClient.thought } }
+ )
+}
+
+async function createUser (prismaClient, data) {
+ const { password, ...newUser } = await prismaClient.user.create({ data })
+ return newUser
+}
\ No newline at end of file
diff --git a/examples/todo-typescript/main.wasp b/examples/todo-typescript/main.wasp
index 9e7faca4f..125cec12a 100644
--- a/examples/todo-typescript/main.wasp
+++ b/examples/todo-typescript/main.wasp
@@ -1,6 +1,6 @@
app TodoTypescript {
wasp: {
- version: "^0.8.2"
+ version: "^0.9.0"
},
title: "ToDo TypeScript",
diff --git a/examples/todo-typescript/src/client/MainPage.tsx b/examples/todo-typescript/src/client/MainPage.tsx
index 917036a27..442200552 100644
--- a/examples/todo-typescript/src/client/MainPage.tsx
+++ b/examples/todo-typescript/src/client/MainPage.tsx
@@ -35,16 +35,16 @@ export function MainPage() {
};
function Todo({ id, isDone, description }: Task) {
- const handleIsDoneChange = async (event: FormEventHandler) => {
+ const handleIsDoneChange: FormEventHandler = async (event) => {
try {
await updateTask({
- taskId: id,
+ id,
isDone: event.currentTarget.checked,
});
} catch (err: any) {
window.alert('Error while updating task ' + err?.message);
}
- };
+ }
return (
@@ -58,8 +58,8 @@ function TasksList({tasks}: { tasks: Task[] }) {
if (tasks.length === 0) return No tasks yet.
;
return (
- {tasks.map((tsk, idx) => (
-
+ {tasks.map((task, idx) => (
+
))}
);
diff --git a/examples/todo-typescript/src/server/actions.ts b/examples/todo-typescript/src/server/actions.ts
index 9162db4ca..5d34ec8ef 100644
--- a/examples/todo-typescript/src/server/actions.ts
+++ b/examples/todo-typescript/src/server/actions.ts
@@ -16,7 +16,6 @@ export async function createTask({ description }: CreateArgs, context: Context)
});
};
-// type UpdateArgs = { taskId: Task['id']; isDone: Task['isDone'] };
type UpdateArgs = Pick;
export async function updateTask({ id, isDone }: UpdateArgs, context: Context) {
diff --git a/examples/tutorials/TodoApp/main.wasp b/examples/tutorials/TodoApp/main.wasp
index 727ad456d..28e05d2c1 100644
--- a/examples/tutorials/TodoApp/main.wasp
+++ b/examples/tutorials/TodoApp/main.wasp
@@ -1,6 +1,6 @@
app TodoApp {
wasp: {
- version: "^0.8.0"
+ version: "^0.10.0"
},
title: "Todo app",
diff --git a/examples/tutorials/TodoApp/src/client/LoginPage.jsx b/examples/tutorials/TodoApp/src/client/LoginPage.jsx
index fbe42d6b4..f8c8f65c8 100644
--- a/examples/tutorials/TodoApp/src/client/LoginPage.jsx
+++ b/examples/tutorials/TodoApp/src/client/LoginPage.jsx
@@ -1,17 +1,19 @@
import { Link } from 'react-router-dom'
-import LoginForm from '@wasp/auth/forms/Login'
+import { LoginForm } from '@wasp/auth/forms/Login'
const LoginPage = () => {
return (
<>
-
-
-
- I don't have an account yet ( go to signup).
-
+
+
+
+
+ I don't have an account yet ( go to signup).
+
+
>
)
}
-export default LoginPage
+export default LoginPage
\ No newline at end of file
diff --git a/examples/tutorials/TodoApp/src/client/SignupPage.jsx b/examples/tutorials/TodoApp/src/client/SignupPage.jsx
index f06c2e75a..5e0d79005 100644
--- a/examples/tutorials/TodoApp/src/client/SignupPage.jsx
+++ b/examples/tutorials/TodoApp/src/client/SignupPage.jsx
@@ -1,17 +1,19 @@
import { Link } from 'react-router-dom'
-import SignupForm from '@wasp/auth/forms/Signup'
+import { SignupForm } from '@wasp/auth/forms/Signup'
const SignupPage = () => {
return (
<>
-
-
-
- I already have an account ( go to login).
-
+
+
+
+
+ I already have an account ( go to login).
+
+
>
)
}
-export default SignupPage
+export default SignupPage
\ No newline at end of file
diff --git a/examples/waspleau/main.wasp b/examples/waspleau/main.wasp
index 758392b34..fd1ecf4ed 100644
--- a/examples/waspleau/main.wasp
+++ b/examples/waspleau/main.wasp
@@ -1,6 +1,6 @@
app waspleau {
wasp: {
- version: "^0.8.0"
+ version: "^0.10.0"
},
title: "Waspleau",
diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md
index 01ef25853..6727b6f58 100644
--- a/waspc/ChangeLog.md
+++ b/waspc/ChangeLog.md
@@ -1,5 +1,233 @@
# Changelog
+## v0.10.0
+
+### Breaking changes
+
+- We changed `LoginForm` and `SignupForm` to use a named export instead of a default export, this means you must use them like this:
+ - `import { LoginForm } from '@wasp/auth/forms/Login'`
+ - `import { SignupForm } from '@wasp/auth/Signup'`
+- We renamed `useAuth.js` to `useAuth.ts` and you should import it like this: `import useAuth from '@wasp/auth/useAuth'` (without the `.js` extension)
+- We changed the type arguments for `useQuery` and `useAction` hooks. They now take two arguments (the `Error` type argument was removed):
+ - `Input` - This type argument specifies the type for the **request's payload**.
+ - `Output` - This type argument specifies the type for the **resposne's payload**.
+
+### Full-stack type safety for Operations
+Frontend code can now infer correct payload/response types for Queries and Actions from their definitions on the server.
+
+Define a Query on the server:
+```typescript
+export const getTask: GetTaskInfo, Task> =
+ async ({ id }, context) => {
+ // ...
+ }
+```
+
+Get properly typed functions and data on the frontend:
+```typescript
+import { useQuery } from "@wasp/queries"
+// Wasp knows the type of `getTask` thanks to your backend definition.
+import getTask from "@wasp/queries/getTask"
+
+export const TaskInfo = () => {
+ const {
+ // TypeScript knows `task` is a `Task | undefined` thanks to the
+ // backend definition.
+ data: task,
+ // TypeScript knows `isError` is a `boolean`.
+ isError,
+ // TypeScript knows `error` is of type `Error`.
+ error,
+ // TypeScript knows the second argument must be a `Pick` thanks
+ // to the backend definition.
+ } = useQuery(getTask, { id: 1 })
+
+ if (isError) {
+ return Error during fetching tasks: {error.message || "unknown"}
+ }
+
+ // TypeScript forces you to perform this check.
+ return taskInfo === undefined ? (
+ Waiting for info...
+ ) : (
+ {taskInfo}
+ )
+}
+```
+The same feature is available for Actions.
+
+### Payloads compatible with Superjson
+Client and the server can now communicate with richer payloads.
+
+Return a Superjson-compatible object from your Operation:
+```typescript
+type FooInfo = { foos: Foo[], message: string, queriedAt: Date }
+
+const getFoos: GetFoo = (_args, context) => {
+ const foos = context.entities.Foo.findMany()
+ return {
+ foos,
+ message: "Here are some foos!",
+ queriedAt: new Date(),
+ }
+}
+```
+And seamlessly use it on the frontend:
+
+```typescript
+import getfoos from "@wasp/queries/getTask"
+
+const { data } = useQuery(getfoos)
+const { foos, message, queriedAt } = data
+// foos: Foo[]
+// message: string
+// queriedAt: Date
+```
+
+### E-mail authentication
+
+You can now use e-mail authentication in your Wasp app! This means that users can sign up and log in using their e-mail address. You get e-mail verification and password reset out of the box.
+
+```c
+app MyApp {
+ // ...
+ auth: {
+ // ...
+ email: {
+ fromField: {
+ name: "ToDO App",
+ email: "hello@itsme.com"
+ },
+ emailVerification: {
+ allowUnverifiedLogin: false,
+ getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js",
+ clientRoute: EmailVerificationRoute,
+ },
+ passwordReset: {
+ getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
+ clientRoute: PasswordResetRoute
+ },
+ },
+ }
+}
+```
+
+You can only use one of e-mail or username & password authentication in your app. You can't use both at the same time.
+
+### Auth UI components
+
+Wasp now provides a set of UI components for authentication. You can use them to quickly build a login and signup page for your app. The UI changes dynamically based on your Wasp config.
+
+We provide `LoginForm`, `SignupForm`, `ForgotPassworForm`, `ResetPasswordForm` and`VerifyEmailForm` components. You can import them from `@wasp/auth/forms` like:
+
+```js
+import { LoginForm } from '@wasp/auth/forms/Login'
+import { SignupForm } from '@wasp/auth/forms/Signup'
+import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword'
+import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword'
+import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail'
+```
+
+### Database seeding
+You can now define JS/TS functions for seeding the database!
+
+```c
+app MyApp {
+ // ...
+ db: {
+ seeds: [
+ import { devSeedSimple } from "@server/dbSeeds.js",
+ import { prodSeed } from "@server/dbSeeds.js",
+ ]
+ }
+}
+```
+
+```js
+import { createTask } from './actions.js'
+
+export const devSeedSimple = async (prismaClient) => {
+ const { password, ...newUser } = await prismaClient.user.create({
+ username: "RiuTheDog", password: "bark1234"
+ })
+ await createTask(
+ { description: "Chase the cat" },
+ { user: newUser, entities: { Task: prismaClient.task } }
+ )
+}
+
+//...
+```
+
+Run `wasp db seed` to run database seeding. If there is only one seed, it will run that one, or it will interactively ask you to pick one.
+You can also do `wasp db seed ` to run a seed with specific name: for example, for the case above, you could do `wasp db seed prodSeed`.
+
+
+### The `api` keyword for defining an arbitrary endpoint and URL
+Need a specific endpoint, like `/healthcheck` or `/foo/callback`? Or need complete control of the response? Use an `api` to define one by tying a JS function to any HTTP method and path! For example:
+```ts
+// main.wasp
+api fooBar {
+ fn: import { foo } from "@server/apis.js",
+ entities: [Task],
+ httpRoute: (GET, "/foo/callback")
+}
+
+// server/api.ts
+import { FooBar } from '@wasp/apis/types'
+
+export const fooBar : FooBar = (req, res, context) => {
+ res.set('Access-Control-Allow-Origin', '*') // Example of modifying headers to override Wasp default CORS middleware.
+ res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` })
+}
+```
+
+### E-mail sending support
+
+Wasp now supports sending e-mails! You can use the `emailSender` app property to configure the e-mail provider and optionally the `defaultFrom` address. Then, you can use the `send` function in your backend code to send e-mails.
+
+```ts
+// main.wasp
+app MyApp {
+ emailSender: {
+ provider: SendGrid,
+ defaultFrom: {
+ name: "My App",
+ email: "myapp@domain.com"
+ },
+ },
+}
+
+// server/actions.ts
+import { emailSender } from '@wasp/email/index.js'
+
+// In some action handler...
+const info = await emailSender.send({
+ to: 'user@domain.com',
+ subject: 'Saying hello',
+ text: 'Hello world',
+ html: 'Hello world '
+})
+```
+
+### `wasp start db` -> Wasp can now run your dev database for you with a single command
+
+Moving from SQLite to PostgreSQL with Wasp can feel like increase in complexity, because suddenly you have to care about running your PostgreSQL database, providing connection URL for it via env var, and if you checkout somebody's else Wasp project, or your old Wasp project that you have no memory of any more, you also have to figure all that out.
+
+To help with that, we now added `wasp start db`, which runs a development database for you!
+That it, all you need to do is run `wasp start db` and you are good to go. No env var setting, no remembering how to run the db.
+
+NOTE: Requires `docker` to be installed and in `PATH`, and docker daemon to be running.
+
+### `wasp test client` -> Wasp can now test your web app code
+By leveraging Vitest and some supporting libraries, Wasp now makes it super easy to add unit tests and React component tests to your frontend codebase.
+
+### `pg-boss` upgraded to latest version (8.4.2)
+This `pg-boss` release fixes an issue where the node server would exit due to an unhandled exception when the DB connection was lost.
+
+### Bug fixes
+- Starts the process of removing the coupling between `usernameAndPassword` and social logins. Now, your `userEntity` no longer requires a `username` or `password` field if you only want to use Google/GitHub for auth.
+
## v0.9.0
### BREAKING CHANGES
@@ -10,7 +238,7 @@
We moved away from using Create React App for the client app. This means that dev startup time will be much faster and we are following the latest best practices for building web apps with React.
### Express `app` and http `server` available in server `setupFn`
-- Wasp now passes in a context to the server `setupFn` that contains Express `app` and http `server` objects. This can be used as an escape hatch for things like custom routes or WebSocket support.
+Wasp now passes in a context to the server `setupFn` that contains Express `app` and http `server` objects. This can be used as an escape hatch for things like custom routes or WebSocket support.
## v0.8.2
diff --git a/waspc/cabal.project b/waspc/cabal.project
index 3077866d8..a129678a1 100644
--- a/waspc/cabal.project
+++ b/waspc/cabal.project
@@ -20,4 +20,4 @@ jobs: $ncpus
test-show-details: direct
-- WARNING: Run cabal update if your local package index is older than this date.
-index-state: 2022-03-22T14:16:26Z
+index-state: 2023-03-09T09:15:49Z
diff --git a/waspc/cli/exe/Main.hs b/waspc/cli/exe/Main.hs
index b02cfdd3d..fec6b91ee 100644
--- a/waspc/cli/exe/Main.hs
+++ b/waspc/cli/exe/Main.hs
@@ -5,6 +5,7 @@ import qualified Control.Concurrent.Async as Async
import qualified Control.Exception as E
import Control.Monad (void)
import Data.Char (isSpace)
+import Data.List (intercalate)
import Main.Utf8 (withUtf8)
import System.Environment (getArgs)
import Wasp.Cli.Command (runCommand)
@@ -14,14 +15,19 @@ import qualified Wasp.Cli.Command.Call as Command.Call
import Wasp.Cli.Command.Clean (clean)
import Wasp.Cli.Command.Compile (compile)
import Wasp.Cli.Command.CreateNewProject (createNewProject)
-import Wasp.Cli.Command.Db (runDbCommand, studio)
+import Wasp.Cli.Command.Db (runDbCommand)
import qualified Wasp.Cli.Command.Db.Migrate as Command.Db.Migrate
+import qualified Wasp.Cli.Command.Db.Reset as Command.Db.Reset
+import qualified Wasp.Cli.Command.Db.Seed as Command.Db.Seed
+import qualified Wasp.Cli.Command.Db.Studio as Command.Db.Studio
import Wasp.Cli.Command.Deploy (deploy)
import Wasp.Cli.Command.Deps (deps)
import Wasp.Cli.Command.Dockerfile (printDockerfile)
import Wasp.Cli.Command.Info (info)
import Wasp.Cli.Command.Start (start)
+import qualified Wasp.Cli.Command.Start.Db as Command.Start.Db
import qualified Wasp.Cli.Command.Telemetry as Telemetry
+import Wasp.Cli.Command.Test (test)
import Wasp.Cli.Command.Uninstall (uninstall)
import Wasp.Cli.Command.WaspLS (runWaspLS)
import Wasp.Cli.Terminal (title)
@@ -33,8 +39,9 @@ main :: IO ()
main = withUtf8 . (`E.catch` handleInternalErrors) $ do
args <- getArgs
let commandCall = case args of
- ["new", projectName] -> Command.Call.New projectName
+ ("new" : projectName : newArgs) -> Command.Call.New projectName newArgs
["start"] -> Command.Call.Start
+ ["start", "db"] -> Command.Call.StartDb
["clean"] -> Command.Call.Clean
["compile"] -> Command.Call.Compile
("db" : dbArgs) -> Command.Call.Db dbArgs
@@ -50,13 +57,15 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
["completion:list"] -> Command.Call.BashCompletionListCommands
("waspls" : _) -> Command.Call.WaspLS
("deploy" : deployArgs) -> Command.Call.Deploy deployArgs
+ ("test" : testArgs) -> Command.Call.Test testArgs
_ -> Command.Call.Unknown args
telemetryThread <- Async.async $ runCommand $ Telemetry.considerSendingData commandCall
case commandCall of
- Command.Call.New projectName -> runCommand $ createNewProject projectName
+ Command.Call.New projectName newArgs -> runCommand $ createNewProject projectName newArgs
Command.Call.Start -> runCommand start
+ Command.Call.StartDb -> runCommand Command.Start.Db.start
Command.Call.Clean -> runCommand clean
Command.Call.Compile -> runCommand compile
Command.Call.Db dbArgs -> dbCli dbArgs
@@ -73,6 +82,7 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
Command.Call.Unknown _ -> printUsage
Command.Call.WaspLS -> runWaspLS
Command.Call.Deploy deployArgs -> runCommand $ deploy deployArgs
+ Command.Call.Test testArgs -> runCommand $ test testArgs
-- If sending of telemetry data is still not done 1 second since commmand finished, abort it.
-- We also make sure here to catch all errors that might get thrown and silence them.
@@ -89,36 +99,46 @@ printUsage :: IO ()
printUsage =
putStrLn $
unlines
+{- ORMOLU_DISABLE -}
[ title "USAGE",
- " wasp [command-args]",
- "",
+ " wasp [command-args]",
+ "",
title "COMMANDS",
title " GENERAL",
- cmd " new Creates new Wasp project.",
- cmd " version Prints current version of CLI.",
- cmd " waspls Run Wasp Language Server. Add --help to get more info.",
- cmd " completion Prints help on bash completion.",
- cmd " uninstall Removes Wasp from your system.",
+ cmd " new [args] Creates a new Wasp project.",
+ " OPTIONS:",
+ " -t|--template ",
+ " Check out the templates list here: https://github.com/wasp-lang/starters",
+ "",
+ cmd " version Prints current version of CLI.",
+ cmd " waspls Run Wasp Language Server. Add --help to get more info.",
+ cmd " completion Prints help on bash completion.",
+ cmd " uninstall Removes Wasp from your system.",
title " IN PROJECT",
- cmd " start Runs Wasp app in development mode, watching for file changes.",
- cmd " db [args] Executes a database command. Run 'wasp db' for more info.",
- cmd " clean Deletes all generated code and other cached artifacts. Wasp equivalent of 'have you tried closing and opening it again?'.",
- cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.",
- cmd " deploy Deploys your Wasp app to cloud hosting providers.",
- cmd " telemetry Prints telemetry status.",
- cmd " deps Prints the dependencies that Wasp uses in your project.",
- cmd " dockerfile Prints the contents of the Wasp generated Dockerfile.",
- cmd " info Prints basic information about current Wasp project.",
- "",
+ cmd " start Runs Wasp app in development mode, watching for file changes.",
+ cmd " start db Starts managed development database for you.",
+ cmd " db [args] Executes a database command. Run 'wasp db' for more info.",
+ cmd " clean Deletes all generated code and other cached artifacts.",
+ " Wasp equivalent of 'have you tried closing and opening it again?'.",
+ cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.",
+ cmd " deploy Deploys your Wasp app to cloud hosting providers.",
+ cmd " telemetry Prints telemetry status.",
+ cmd " deps Prints the dependencies that Wasp uses in your project.",
+ cmd " dockerfile Prints the contents of the Wasp generated Dockerfile.",
+ cmd " info Prints basic information about current Wasp project.",
+ cmd " test Executes tests in your project.",
+ "",
title "EXAMPLES",
- " wasp new MyApp",
- " wasp start",
- " wasp db migrate-dev",
- "",
- Term.applyStyles [Term.Green] "Docs:" ++ " https://wasp-lang.dev/docs",
+ " wasp new MyApp",
+ " wasp new MyApp -t waspello",
+ " wasp start",
+ " wasp db migrate-dev",
+ "",
+ Term.applyStyles [Term.Green] "Docs:" ++ " https://wasp-lang.dev/docs",
Term.applyStyles [Term.Magenta] "Discord (chat):" ++ " https://discord.gg/rzdnErX",
- Term.applyStyles [Term.Cyan] "Newsletter:" ++ " https://wasp-lang.dev/#signup"
+ Term.applyStyles [Term.Cyan] "Newsletter:" ++ " https://wasp-lang.dev/#signup"
]
+{- ORMOLU_ENABLE -}
printVersion :: IO ()
printVersion = do
@@ -128,44 +148,56 @@ printVersion = do
"",
"If you wish to install/switch to the latest version of Wasp, do:",
" curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s",
- "or do",
- " curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v x.y.z",
- "if you want specific x.y.z version of Wasp.",
"",
- "Check https://github.com/wasp-lang/wasp/releases for the list of valid versions, include the latest one."
+ "If you want specific x.y.z version of Wasp, do:",
+ " curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v x.y.z",
+ "",
+ "Check https://github.com/wasp-lang/wasp/releases for the list of valid versions, including the latest one."
]
-- TODO(matija): maybe extract to a separate module, e.g. DbCli.hs?
dbCli :: [String] -> IO ()
dbCli args = case args of
+ ["start"] -> runCommand Command.Start.Db.start
"migrate-dev" : optionalMigrateArgs -> runDbCommand $ Command.Db.Migrate.migrateDev optionalMigrateArgs
- ["studio"] -> runDbCommand studio
+ ["reset"] -> runDbCommand Command.Db.Reset.reset
+ ["seed"] -> runDbCommand $ Command.Db.Seed.seed Nothing
+ ["seed", seedName] -> runDbCommand $ Command.Db.Seed.seed $ Just seedName
+ ["studio"] -> runDbCommand Command.Db.Studio.studio
_ -> printDbUsage
printDbUsage :: IO ()
printDbUsage =
putStrLn $
unlines
+{- ORMOLU_DISABLE -}
[ title "USAGE",
- " wasp db [command-args]",
- "",
+ " wasp db [command-args]",
+ "",
title "COMMANDS",
- cmd
- ( " migrate-dev Ensures dev database corresponds to the current state of schema(entities):\n"
- <> " - Generates a new migration if there are changes in the schema.\n"
- <> " - Applies any pending migrations to the database either using the supplied migration name or asking for one.\n"
- <> "\nOPTIONS:\n"
- <> " --name [migration-name]\n"
- <> " --create-only\n"
- ),
- cmd " studio GUI for inspecting your database.",
- "",
+ cmd " start Alias for `wasp start db`.",
+ cmd " reset Drops all data and tables from development database and re-applies all migrations.",
+ cmd " seed [name] Executes a db seed function (specified via app.db.seeds).",
+ cmd " If there are multiple seeds, you can specify a seed to execute by providing its name,",
+ cmd " or if not then you will be asked to provide the name interactively.",
+ cmd $ intercalate "\n" [
+ " migrate-dev Ensures dev database corresponds to the current state of schema(entities):",
+ " - Generates a new migration if there are changes in the schema.",
+ " - Applies any pending migrations to the database either using the",
+ " supplied migration name or asking for one.",
+ " OPTIONS:",
+ " --name [migration-name]",
+ " --create-only"
+ ],
+ cmd " studio GUI for inspecting your database.",
+ "",
title "EXAMPLES",
- " wasp db migrate-dev",
- " wasp db migrate-dev --name \"Added User entity\"",
- " wasp db migrate-dev --create-only",
- " wasp db studio"
+ " wasp db migrate-dev",
+ " wasp db migrate-dev --name \"Added User entity\"",
+ " wasp db migrate-dev --create-only",
+ " wasp db studio"
]
+{- ORMOLU_ENABLE -}
cmd :: String -> String
cmd = mapFirstWord (Term.applyStyles [Term.Yellow, Term.Bold])
diff --git a/waspc/cli/src/Wasp/Cli/Command/Build.hs b/waspc/cli/src/Wasp/Cli/Command/Build.hs
index 6aed94fdb..906de11f0 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Build.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Build.hs
@@ -21,10 +21,10 @@ import Wasp.Cli.Command.Message (cliSendMessageC)
import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
import Wasp.CompileOptions (CompileOptions (..))
+import qualified Wasp.Generator
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
-import Wasp.Lib (CompileError, CompileWarning)
-import qualified Wasp.Lib
import qualified Wasp.Message as Msg
+import Wasp.Project (CompileError, CompileWarning)
-- | Builds Wasp project that the current working directory is part of.
-- Does all the steps, from analysis to generation, and at the end writes generated code
@@ -61,7 +61,7 @@ build = do
buildIO ::
Path' Abs (Dir Common.WaspProjectDir) ->
- Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
+ Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
IO ([CompileWarning], [CompileError])
buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir buildDir
where
diff --git a/waspc/cli/src/Wasp/Cli/Command/Call.hs b/waspc/cli/src/Wasp/Cli/Command/Call.hs
index e931cf4ae..994dae1a9 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Call.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Call.hs
@@ -1,12 +1,13 @@
module Wasp.Cli.Command.Call where
data Call
- = New String -- project name
+ = New ProjectName Arguments -- project name, new args
| Start
+ | StartDb
| Clean
| Uninstall
| Compile
- | Db [String] -- db args
+ | Db Arguments -- db args
| Build
| Version
| Telemetry
@@ -17,5 +18,10 @@ data Call
| GenerateBashCompletionScript
| BashCompletionListCommands
| WaspLS
- | Deploy [String] -- deploy cmd passthrough args
- | Unknown [String] -- all args
+ | Deploy Arguments -- deploy cmd passthrough args
+ | Test Arguments -- "client" | "server", then test cmd passthrough args
+ | Unknown Arguments -- all args
+
+type ProjectName = String
+
+type Arguments = [String]
diff --git a/waspc/cli/src/Wasp/Cli/Command/Common.hs b/waspc/cli/src/Wasp/Cli/Command/Common.hs
index e15d789d6..a7642711a 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Common.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Common.hs
@@ -1,24 +1,25 @@
module Wasp.Cli.Command.Common
( findWaspProjectRootDirFromCwd,
findWaspProjectRoot,
+ readWaspCompileInfo,
+ throwIfExeIsNotAvailable,
)
where
-import Control.Monad (unless, when)
-import Control.Monad.Except (throwError)
-import Control.Monad.IO.Class (liftIO)
+import Control.Monad.Except
+import qualified Control.Monad.Except as E
import Data.Maybe (fromJust)
import StrongPath (Abs, Dir, Path')
import qualified StrongPath as SP
-import System.Directory
- ( doesFileExist,
- doesPathExist,
- getCurrentDirectory,
- )
+import StrongPath.Operations
+import System.Directory (doesFileExist, doesPathExist, findExecutable, getCurrentDirectory)
import qualified System.FilePath as FP
import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Common (dotWaspRootFileInWaspProjectDir)
-import Wasp.Common (WaspProjectDir)
+import qualified Wasp.Cli.Common as Cli.Common
+import Wasp.Project (WaspProjectDir)
+import Wasp.Util (ifM)
+import qualified Wasp.Util.IO as IOUtil
findWaspProjectRoot :: Path' Abs (Dir ()) -> Command (Path' Abs (Dir WaspProjectDir))
findWaspProjectRoot currentDir = do
@@ -45,3 +46,23 @@ findWaspProjectRootDirFromCwd :: Command (Path' Abs (Dir WaspProjectDir))
findWaspProjectRootDirFromCwd = do
absCurrentDir <- liftIO getCurrentDirectory
findWaspProjectRoot (fromJust $ SP.parseAbsDir absCurrentDir)
+
+readWaspCompileInfo :: Path' Abs (Dir WaspProjectDir) -> IO String
+readWaspCompileInfo waspDir =
+ ifM
+ (IOUtil.doesFileExist dotWaspInfoFile)
+ (IOUtil.readFile dotWaspInfoFile)
+ (return "No compile information found")
+ where
+ dotWaspInfoFile =
+ waspDir > Cli.Common.dotWaspDirInWaspProjectDir
+ > Cli.Common.generatedCodeDirInDotWaspDir
+ > Cli.Common.dotWaspInfoFileInGeneratedCodeDir
+
+throwIfExeIsNotAvailable :: String -> String -> Command ()
+throwIfExeIsNotAvailable exeName explanationMsg = do
+ liftIO (findExecutable exeName) >>= \case
+ Just _ -> return ()
+ Nothing ->
+ E.throwError $
+ CommandError ("Couldn't find `" <> exeName <> "` executable") explanationMsg
diff --git a/waspc/cli/src/Wasp/Cli/Command/Compile.hs b/waspc/cli/src/Wasp/Cli/Command/Compile.hs
index bb9231ceb..8c6c62010 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Compile.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Compile.hs
@@ -6,6 +6,8 @@ module Wasp.Cli.Command.Compile
defaultCompileOptions,
printCompilationResult,
printWarningsAndErrorsIfAny,
+ analyze,
+ analyzeWithOptions,
)
where
@@ -14,6 +16,7 @@ import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.List (intercalate)
import StrongPath (Abs, Dir, Path', (>))
+import qualified Wasp.AppSpec as AS
import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Command.Common
( findWaspProjectRootDirFromCwd,
@@ -21,11 +24,11 @@ import Wasp.Cli.Command.Common
import Wasp.Cli.Command.Message (cliSendMessageC)
import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
-import Wasp.Common (WaspProjectDir)
import Wasp.CompileOptions (CompileOptions (..))
-import Wasp.Lib (CompileError, CompileWarning)
-import qualified Wasp.Lib
+import qualified Wasp.Generator
import qualified Wasp.Message as Msg
+import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
+import qualified Wasp.Project
-- | Same like 'compileWithOptions', but with default compile options.
compile :: Command [CompileWarning]
@@ -98,18 +101,18 @@ formatErrorOrWarningMessages = intercalate "\n" . map ("- " ++)
-- in given outDir directory.
compileIO ::
Path' Abs (Dir WaspProjectDir) ->
- Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
+ Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
IO ([CompileWarning], [CompileError])
compileIO waspProjectDir outDir =
compileIOWithOptions (defaultCompileOptions waspProjectDir) waspProjectDir outDir
compileIOWithOptions ::
CompileOptions ->
- Path' Abs (Dir Common.WaspProjectDir) ->
- Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
+ Path' Abs (Dir WaspProjectDir) ->
+ Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
IO ([CompileWarning], [CompileError])
compileIOWithOptions options waspProjectDir outDir =
- Wasp.Lib.compile waspProjectDir outDir options
+ Wasp.Project.compile waspProjectDir outDir options
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
defaultCompileOptions waspProjectDir =
@@ -121,3 +124,19 @@ defaultCompileOptions waspProjectDir =
sendMessage = cliSendMessage,
generatorWarningsFilter = id
}
+
+analyze :: Path' Abs (Dir WaspProjectDir) -> Command AS.AppSpec
+analyze waspProjectDir = do
+ analyzeWithOptions waspProjectDir $ defaultCompileOptions waspProjectDir
+
+-- | Analyzes Wasp project that the current working directory is a part of and returns
+-- AppSpec. So same like compilation, but it stops before any code generation.
+-- Throws if there were any compilation errors.
+analyzeWithOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions -> Command AS.AppSpec
+analyzeWithOptions waspProjectDir options = do
+ liftIO (Wasp.Project.analyzeWaspProject waspProjectDir options) >>= \case
+ Left errors ->
+ throwError $
+ CommandError "Analyzing wasp project failed" $
+ show (length errors) <> " errors found:\n" <> formatErrorOrWarningMessages errors
+ Right spec -> return spec
diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
index 9bfc62806..43489f58e 100644
--- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs
@@ -3,50 +3,63 @@ module Wasp.Cli.Command.CreateNewProject
)
where
+import Control.Monad (when)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.List (intercalate)
+import Data.Maybe (isJust)
+import qualified Data.Text as T
import Path.IO (copyDirRecur, doesDirExist)
import StrongPath (Abs, Dir, Path, Path', System, parseAbsDir, reldir, relfile, (>))
import StrongPath.Path (toPathAbsDir)
import System.Directory (getCurrentDirectory)
import qualified System.FilePath as FP
+import System.Process (callCommand)
import Text.Printf (printf)
+import UnliftIO.Exception (SomeException, try)
import Wasp.Analyzer.Parser (isValidWaspIdentifier)
import Wasp.Cli.Command (Command, CommandError (..))
-import Wasp.Common (WaspProjectDir)
-import qualified Wasp.Common as Common (WaspProjectDir)
+import Wasp.Cli.Command.Call (Arguments, ProjectName)
+import Wasp.Cli.Command.Message (cliSendMessageC)
import qualified Wasp.Data as Data
-import Wasp.Util (indent, kebabToCamelCase)
+import qualified Wasp.Message as Msg
+import Wasp.Project (WaspProjectDir)
+import qualified Wasp.SemanticVersion as SV
+import Wasp.Util (indent, kebabToCamelCase, whenM)
import qualified Wasp.Util.IO as IOUtil
import qualified Wasp.Util.Terminal as Term
import qualified Wasp.Version as WV
data ProjectInfo = ProjectInfo
{ _projectName :: String,
- _appName :: String
+ _appName :: String,
+ _templateName :: Maybe String
}
-createNewProject :: String -> Command ()
-createNewProject projectNameCandidate = do
- projectInfo <- parseProjectInfo projectNameCandidate
+createNewProject :: ProjectName -> Arguments -> Command ()
+createNewProject projectName newArgs = do
+ projectInfo <- parseProjectInfo projectName newArgs
createWaspProjectDir projectInfo
liftIO $ printGettingStartedInstructions $ _projectName projectInfo
where
- printGettingStartedInstructions :: String -> IO ()
- printGettingStartedInstructions projectName = do
- putStrLn $ Term.applyStyles [Term.Green] ("Created new Wasp app in ./" ++ projectName ++ " directory!")
+ printGettingStartedInstructions :: ProjectName -> IO ()
+ printGettingStartedInstructions projectFolder = do
+ putStrLn $ Term.applyStyles [Term.Green] ("Created new Wasp app in ./" ++ projectFolder ++ " directory!")
putStrLn "To run it, do:"
putStrLn ""
- putStrLn $ Term.applyStyles [Term.Bold] (" cd " ++ projectName)
+ putStrLn $ Term.applyStyles [Term.Bold] (" cd " ++ projectFolder)
putStrLn $ Term.applyStyles [Term.Bold] " wasp start"
--- Takes a project name String
--- Returns either the ProjectInfo type that contains both the Project name
--- and the App name (which might be the same), or an error describing why the name is invalid
-parseProjectInfo :: String -> Command ProjectInfo
-parseProjectInfo name
- | isValidWaspIdentifier appName = return $ ProjectInfo name appName
+parseProjectInfo :: ProjectName -> Arguments -> Command ProjectInfo
+parseProjectInfo projectName newArgs = case newArgs of
+ [] -> createProjectInfo projectName Nothing
+ [templateFlag, templateName] | templateFlag `elem` ["--template", "-t"] -> createProjectInfo projectName (Just templateName)
+ [templateFlag] | templateFlag `elem` ["--template", "-t"] -> throwProjectCreationError "You must provide a template name."
+ _anyOtherArgs -> throwProjectCreationError "Invalid arguments for 'wasp new' command."
+
+createProjectInfo :: ProjectName -> Maybe String -> Command ProjectInfo
+createProjectInfo name templateName
+ | isValidWaspIdentifier appName = return $ ProjectInfo {_projectName = name, _appName = appName, _templateName = templateName}
| otherwise =
throwProjectCreationError $
intercalate
@@ -60,17 +73,25 @@ parseProjectInfo name
appName = kebabToCamelCase name
createWaspProjectDir :: ProjectInfo -> Command ()
-createWaspProjectDir projectInfo = do
+createWaspProjectDir projectInfo@ProjectInfo {_templateName = template} = do
absWaspProjectDir <- getAbsoluteWaspProjectDir projectInfo
dirExists <- doesDirExist $ toPathAbsDir absWaspProjectDir
- if dirExists
- then throwProjectCreationError $ show absWaspProjectDir ++ " is an existing directory"
- else liftIO $ do
- initializeProjectFromSkeleton absWaspProjectDir
- writeMainWaspFile absWaspProjectDir projectInfo
+
+ when dirExists $
+ throwProjectCreationError $
+ show absWaspProjectDir ++ " is an existing directory"
+
+ createProjectFromProjectInfo absWaspProjectDir
+ where
+ createProjectFromProjectInfo absWaspProjectDir = do
+ if isJust template
+ then createProjectFromTemplate absWaspProjectDir projectInfo
+ else liftIO $ do
+ initializeProjectFromSkeleton absWaspProjectDir
+ writeMainWaspFile absWaspProjectDir projectInfo
getAbsoluteWaspProjectDir :: ProjectInfo -> Command (Path System Abs (Dir WaspProjectDir))
-getAbsoluteWaspProjectDir (ProjectInfo projectName _) = do
+getAbsoluteWaspProjectDir (ProjectInfo projectName _ _) = do
absCwd <- liftIO getCurrentDirectory
case parseAbsDir $ absCwd FP.> projectName of
Right sp -> return sp
@@ -79,21 +100,21 @@ getAbsoluteWaspProjectDir (ProjectInfo projectName _) = do
"Failed to parse absolute path to wasp project dir: " ++ show err
-- Copies prepared files to the new project directory.
-initializeProjectFromSkeleton :: Path' Abs (Dir Common.WaspProjectDir) -> IO ()
+initializeProjectFromSkeleton :: Path' Abs (Dir WaspProjectDir) -> IO ()
initializeProjectFromSkeleton absWaspProjectDir = do
dataDir <- Data.getAbsDataDirPath
let absSkeletonDir = dataDir > [reldir|Cli/templates/new|]
copyDirRecur (toPathAbsDir absSkeletonDir) (toPathAbsDir absWaspProjectDir)
writeMainWaspFile :: Path System Abs (Dir WaspProjectDir) -> ProjectInfo -> IO ()
-writeMainWaspFile waspProjectDir (ProjectInfo projectName appName) = IOUtil.writeFile absMainWaspFile mainWaspFileContent
+writeMainWaspFile waspProjectDir (ProjectInfo projectName appName _) = IOUtil.writeFile absMainWaspFile mainWaspFileContent
where
absMainWaspFile = waspProjectDir > [relfile|main.wasp|]
mainWaspFileContent =
unlines
[ "app %s {" `printf` appName,
" wasp: {",
- " version: \"^%s\"" `printf` show WV.waspVersion,
+ " version: \"%s\"" `printf` waspVersionBounds,
" },",
" title: \"%s\"" `printf` projectName,
"}",
@@ -104,5 +125,63 @@ writeMainWaspFile waspProjectDir (ProjectInfo projectName appName) = IOUtil.writ
"}"
]
+createProjectFromTemplate :: Path System Abs (Dir WaspProjectDir) -> ProjectInfo -> Command ()
+createProjectFromTemplate absWaspProjectDir ProjectInfo {_appName = appName, _projectName = projectName, _templateName = maybeTemplateName} = do
+ cliSendMessageC $ Msg.Start "Creating project from template..."
+
+ templatePath <- getPathToRemoteTemplate maybeTemplateName
+
+ let projectDir = projectName
+
+ fetchTemplate templatePath projectDir
+ ensureTemplateWasFetched
+
+ replacePlaceholdersInWaspFile
+ where
+ getPathToRemoteTemplate :: Maybe String -> Command String
+ getPathToRemoteTemplate = \case
+ Just templateName -> return $ waspTemplatesRepo ++ "/" ++ templateName
+ Nothing -> throwProjectCreationError "Template name is not provided."
+ where
+ -- gh: prefix means Github repo
+ waspTemplatesRepo = "gh:wasp-lang/starters"
+
+ fetchTemplate :: String -> String -> Command ()
+ fetchTemplate templatePath projectDir = do
+ liftIO (try executeCmd) >>= \case
+ Left (e :: SomeException) -> throwProjectCreationError $ "Failed to create project from template: " ++ show e
+ Right _ -> return ()
+ where
+ executeCmd = callCommand $ unwords command
+ command = ["npx", "giget@latest", templatePath, projectDir]
+
+ -- gitget doesn't fail if the template dir doesn't exist in the repo, so we need to check if the directory exists.
+ ensureTemplateWasFetched :: Command ()
+ ensureTemplateWasFetched =
+ whenM (liftIO $ IOUtil.isDirectoryEmpty absWaspProjectDir) $
+ throwProjectCreationError "Are you sure that the template exists? ๐ค Check the list of templates here: https://github.com/wasp-lang/starters"
+
+ replacePlaceholdersInWaspFile :: Command ()
+ replacePlaceholdersInWaspFile = liftIO $ do
+ mainWaspFileContent <- IOUtil.readFileStrict absMainWaspFile
+
+ let replacedContent =
+ foldl
+ (\acc (placeholder, value) -> T.replace (T.pack placeholder) (T.pack value) acc)
+ mainWaspFileContent
+ replacements
+
+ IOUtil.writeFileFromText absMainWaspFile replacedContent
+ where
+ absMainWaspFile = absWaspProjectDir > [relfile|main.wasp|]
+ replacements =
+ [ ("__waspAppName__", appName),
+ ("__waspProjectName__", projectName),
+ ("__waspVersion__", waspVersionBounds)
+ ]
+
+waspVersionBounds :: String
+waspVersionBounds = show (SV.backwardsCompatibleWith WV.waspVersion)
+
throwProjectCreationError :: String -> Command a
throwProjectCreationError = throwError . CommandError "Project creation failed"
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db.hs b/waspc/cli/src/Wasp/Cli/Command/Db.hs
index afb2d7ff4..66abe13d9 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db.hs
@@ -1,23 +1,13 @@
module Wasp.Cli.Command.Db
( runDbCommand,
- studio,
)
where
-import Control.Concurrent (newChan)
-import Control.Concurrent.Async (concurrently)
-import Control.Monad.IO.Class (liftIO)
-import StrongPath ((>))
import Wasp.Cli.Command (Command, runCommand)
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
import Wasp.Cli.Command.Compile (compileWithOptions, defaultCompileOptions)
-import Wasp.Cli.Command.Message (cliSendMessageC)
-import qualified Wasp.Cli.Common as Common
import Wasp.CompileOptions (CompileOptions (generatorWarningsFilter))
-import Wasp.Generator.DbGenerator.Jobs (runStudio)
-import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed)
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
-import qualified Wasp.Message as Msg
runDbCommand :: Command a -> IO ()
runDbCommand = runCommand . makeDbCommand
@@ -44,17 +34,3 @@ makeDbCommand cmd = do
_ -> True
)
}
-
--- TODO(matija): should we extract this into a separate file, like we did for migrate?
-studio :: Command ()
-studio = do
- waspProjectDir <- findWaspProjectRootDirFromCwd
- let genProjectDir =
- waspProjectDir > Common.dotWaspDirInWaspProjectDir
- > Common.generatedCodeDirInDotWaspDir
-
- cliSendMessageC $ Msg.Start "Running studio..."
- chan <- liftIO newChan
-
- _ <- liftIO $ concurrently (readJobMessagesAndPrintThemPrefixed chan) (runStudio genProjectDir chan)
- error "This should never happen, studio should never stop."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
index 9d487d7d6..d1cd7d802 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs
@@ -13,12 +13,11 @@ import Wasp.Cli.Command.Common
)
import Wasp.Cli.Command.Message (cliSendMessageC)
import qualified Wasp.Cli.Common as Cli.Common
-import Wasp.Common (DbMigrationsDir)
-import qualified Wasp.Common
import Wasp.Generator.Common (ProjectRootDir)
import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), defaultMigrateArgs)
import qualified Wasp.Generator.DbGenerator.Operations as DbOps
import qualified Wasp.Message as Msg
+import Wasp.Project.Db.Migrations (DbMigrationsDir, dbMigrationsDirInWaspProjectDir)
-- | NOTE(shayne): Performs database schema migration (based on current schema) in the generated project.
-- This assumes the wasp project migrations dir was copied from wasp source project by a previous compile.
@@ -26,14 +25,13 @@ import qualified Wasp.Message as Msg
migrateDev :: [String] -> Command ()
migrateDev optionalMigrateArgs = do
waspProjectDir <- findWaspProjectRootDirFromCwd
- let waspDbMigrationsDir = waspProjectDir > Wasp.Common.dbMigrationsDirInWaspProjectDir
+ let waspDbMigrationsDir = waspProjectDir > dbMigrationsDirInWaspProjectDir
let projectRootDir =
waspProjectDir
> Cli.Common.dotWaspDirInWaspProjectDir
> Cli.Common.generatedCodeDirInDotWaspDir
migrateDatabase optionalMigrateArgs projectRootDir waspDbMigrationsDir
- generatePrismaClients projectRootDir
migrateDatabase :: [String] -> Path' Abs (Dir ProjectRootDir) -> Path' Abs (Dir DbMigrationsDir) -> Command ()
migrateDatabase optionalMigrateArgs projectRootDir dbMigrationsDir = do
@@ -46,14 +44,6 @@ migrateDatabase optionalMigrateArgs projectRootDir dbMigrationsDir = do
migrateArgs <- liftEither $ parseMigrateArgs optionalMigrateArgs
ExceptT $ DbOps.migrateDevAndCopyToSource dbMigrationsDir projectRootDir migrateArgs
-generatePrismaClients :: Path' Abs (Dir ProjectRootDir) -> Command ()
-generatePrismaClients projectRootDir = do
- cliSendMessageC $ Msg.Start "Generating prisma clients..."
- generatePrismaClientsResult <- liftIO $ DbOps.generatePrismaClients projectRootDir
- case generatePrismaClientsResult of
- Left err -> throwError $ CommandError "Could not generate Prisma clients" err
- Right () -> cliSendMessageC $ Msg.Success "Prisma clients successfully generated."
-
-- | Basic parsing of db-migrate args. In the future, we could use a smarter parser
-- for this (and all other CLI arg parsing).
parseMigrateArgs :: [String] -> Either String MigrateArgs
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
new file mode 100644
index 000000000..bf5154720
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
@@ -0,0 +1,25 @@
+module Wasp.Cli.Command.Db.Reset
+ ( reset,
+ )
+where
+
+import Control.Monad.IO.Class (liftIO)
+import StrongPath ((>))
+import Wasp.Cli.Command (Command)
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import qualified Wasp.Cli.Common as Common
+import Wasp.Generator.DbGenerator.Operations (dbReset)
+import qualified Wasp.Message as Msg
+
+reset :: Command ()
+reset = do
+ waspProjectDir <- findWaspProjectRootDirFromCwd
+ let genProjectDir =
+ waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+
+ cliSendMessageC $ Msg.Start "Resetting the database..."
+
+ liftIO (dbReset genProjectDir) >>= \case
+ Left errorMsg -> cliSendMessageC $ Msg.Failure "Database reset failed" errorMsg
+ Right () -> cliSendMessageC $ Msg.Success "Database reset successfully!"
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
new file mode 100644
index 000000000..bfa8daa27
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
@@ -0,0 +1,93 @@
+module Wasp.Cli.Command.Db.Seed
+ ( seed,
+ )
+where
+
+import qualified Control.Monad.Except as E
+import Control.Monad.IO.Class (liftIO)
+import Data.List (intercalate)
+import Data.List.NonEmpty (NonEmpty ((:|)))
+import qualified Data.List.NonEmpty as NE
+import StrongPath ((>))
+import Text.Printf (printf)
+import qualified Wasp.AppSpec as AS
+import qualified Wasp.AppSpec.App as AS.App
+import qualified Wasp.AppSpec.App.Db as AS.Db
+import qualified Wasp.AppSpec.ExtImport as AS.ExtImport
+import qualified Wasp.AppSpec.Valid as ASV
+import Wasp.Cli.Command (Command, CommandError (CommandError))
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
+import Wasp.Cli.Command.Compile (analyze)
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import qualified Wasp.Cli.Common as Common
+import Wasp.Generator.DbGenerator.Operations (dbSeed)
+import qualified Wasp.Message as Msg
+
+seed :: Maybe String -> Command ()
+seed maybeUserProvidedSeedName = do
+ waspProjectDir <- findWaspProjectRootDirFromCwd
+ let genProjectDir =
+ waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+
+ appSpec <- analyze waspProjectDir
+
+ nameOfSeedToRun <- obtainNameOfExistingSeedToRun maybeUserProvidedSeedName appSpec
+
+ cliSendMessageC $ Msg.Start $ "Running database seed " <> nameOfSeedToRun <> "..."
+
+ liftIO (dbSeed genProjectDir nameOfSeedToRun) >>= \case
+ Left errorMsg -> cliSendMessageC $ Msg.Failure "Database seeding failed" errorMsg
+ Right () -> cliSendMessageC $ Msg.Success "Database seeded successfully!"
+
+obtainNameOfExistingSeedToRun :: Maybe String -> AS.AppSpec -> Command String
+obtainNameOfExistingSeedToRun maybeUserProvidedSeedName spec = do
+ seedNames <- getSeedNames <$> getSeedsFromAppSpecOrThrowIfNone
+ case maybeUserProvidedSeedName of
+ Just name -> parseUserProvidedSeedName name seedNames
+ Nothing -> case seedNames of
+ seedName :| [] -> return seedName
+ _seedNames -> liftIO $ askUserToChooseFromSeedNames seedNames
+ where
+ askUserToChooseFromSeedNames :: NE.NonEmpty String -> IO String
+ askUserToChooseFromSeedNames seedNames = do
+ putStrLn "Choose a seed to run:"
+ mapM_ (\(i, n) -> putStrLn $ printf " [%d] %s" i n) $ zip [1 :: Int ..] (NE.toList seedNames)
+ putStrLn "Type a number (e.g. 1 or 2):"
+ chosenNumber <- getLine
+ case parseNumberInRange (1, length seedNames) chosenNumber of
+ Right idx -> return $ seedNames NE.!! (idx - 1)
+ Left errMsg -> do
+ putStrLn $ "Invalid number (" <> errMsg <> "), please try again.\n"
+ askUserToChooseFromSeedNames seedNames
+
+ parseNumberInRange :: (Int, Int) -> String -> Either String Int
+ parseNumberInRange (minNum, maxNum) strNum =
+ case reads strNum of
+ [(num, _)] ->
+ if num >= minNum && num <= maxNum
+ then Right num
+ else Left "number out of range"
+ _notANum -> Left "not a number"
+
+ parseUserProvidedSeedName :: String -> NE.NonEmpty String -> Command String
+ parseUserProvidedSeedName userProvidedSeedName seedNames =
+ if userProvidedSeedName `elem` seedNames
+ then return userProvidedSeedName
+ else
+ (E.throwError . CommandError "Invalid seed name") $
+ "There is no seed with the name " <> userProvidedSeedName <> "."
+ <> ("\nValid seed names are: " <> intercalate ", " (NE.toList seedNames) <> ".")
+
+ getSeedsFromAppSpecOrThrowIfNone :: Command (NE.NonEmpty AS.ExtImport.ExtImport)
+ getSeedsFromAppSpecOrThrowIfNone = case getDbSeeds spec of
+ Just seeds@(_ : _) -> return $ NE.fromList seeds
+ _noSeeds ->
+ (E.throwError . CommandError "No seeds defined") $
+ "You haven't defined any database seeding functions, so there is nothing to run!\n"
+ <> "To do so, define seeding functions via app.db.seeds in your Wasp config."
+
+ getSeedNames :: (Functor f) => f AS.ExtImport.ExtImport -> f String
+ getSeedNames seeds = AS.ExtImport.importIdentifier <$> seeds
+
+getDbSeeds :: AS.AppSpec -> Maybe [AS.ExtImport.ExtImport]
+getDbSeeds spec = AS.Db.seeds =<< AS.App.db (snd $ ASV.getApp spec)
diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
new file mode 100644
index 000000000..02cab075c
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
@@ -0,0 +1,29 @@
+module Wasp.Cli.Command.Db.Studio
+ ( studio,
+ )
+where
+
+import Control.Concurrent (newChan)
+import Control.Concurrent.Async (concurrently)
+import Control.Monad.IO.Class (liftIO)
+import StrongPath ((>))
+import Wasp.Cli.Command (Command)
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import qualified Wasp.Cli.Common as Common
+import Wasp.Generator.DbGenerator.Jobs (runStudio)
+import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed)
+import qualified Wasp.Message as Msg
+
+studio :: Command ()
+studio = do
+ waspProjectDir <- findWaspProjectRootDirFromCwd
+ let genProjectDir =
+ waspProjectDir > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+
+ cliSendMessageC $ Msg.Start "Running studio..."
+
+ chan <- liftIO newChan
+ _ <- liftIO $ readJobMessagesAndPrintThemPrefixed chan `concurrently` runStudio genProjectDir chan
+
+ error "This should never happen, studio should never stop."
diff --git a/waspc/cli/src/Wasp/Cli/Command/Deploy.hs b/waspc/cli/src/Wasp/Cli/Command/Deploy.hs
index 8ec04df84..4b68eeaab 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Deploy.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Deploy.hs
@@ -7,13 +7,15 @@ import Control.Monad.IO.Class (liftIO)
import System.Environment (getExecutablePath)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
-import qualified Wasp.Lib as Lib
+import qualified Wasp.Project.Deployment
deploy :: [String] -> Command ()
deploy cmdArgs = do
waspProjectDir <- findWaspProjectRootDirFromCwd
liftIO $ do
- -- `getExecutablePath` has some caveats: https://frasertweedale.github.io/blog-fp/posts/2022-05-10-improved-executable-path-queries.html
- -- Once we upgrade to GHC 9.4 we should change to `executablePath`, but this should be ok for our purposes.
+ -- `getExecutablePath` has some caveats:
+ -- https://frasertweedale.github.io/blog-fp/posts/2022-05-10-improved-executable-path-queries.html
+ -- Once we upgrade to GHC 9.4 we should change to `executablePath`, but this should be ok for
+ -- our purposes.
waspExePath <- getExecutablePath
- Lib.deploy waspExePath waspProjectDir cmdArgs
+ Wasp.Project.Deployment.deploy waspExePath waspProjectDir cmdArgs
diff --git a/waspc/cli/src/Wasp/Cli/Command/Deps.hs b/waspc/cli/src/Wasp/Cli/Command/Deps.hs
index ed7d5acd3..18d0bad11 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Deps.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Deps.hs
@@ -14,7 +14,7 @@ import Wasp.Cli.Terminal (title)
import qualified Wasp.Generator.NpmDependencies as N
import qualified Wasp.Generator.ServerGenerator as ServerGenerator
import qualified Wasp.Generator.WebAppGenerator as WebAppGenerator
-import Wasp.Lib (analyzeWaspProject)
+import Wasp.Project (analyzeWaspProject)
import qualified Wasp.Util.Terminal as Term
deps :: Command ()
diff --git a/waspc/cli/src/Wasp/Cli/Command/Dockerfile.hs b/waspc/cli/src/Wasp/Cli/Command/Dockerfile.hs
index 320b3fc8f..84461398d 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Dockerfile.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Dockerfile.hs
@@ -9,7 +9,7 @@ import qualified Data.Text.IO as T.IO
import Wasp.Cli.Command (Command, CommandError (..))
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
import Wasp.Cli.Command.Compile (defaultCompileOptions)
-import Wasp.Lib (compileAndRenderDockerfile)
+import Wasp.Project (compileAndRenderDockerfile)
printDockerfile :: Command ()
printDockerfile = do
diff --git a/waspc/cli/src/Wasp/Cli/Command/Info.hs b/waspc/cli/src/Wasp/Cli/Command/Info.hs
index e3fd9e299..a623502dd 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Info.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Info.hs
@@ -5,45 +5,43 @@ module Wasp.Cli.Command.Info
)
where
-import Control.Arrow
-import Control.Monad.Except
+import Control.Arrow ()
+import Control.Monad.Except (MonadIO (liftIO))
import StrongPath (Abs, Dir, Path', fromRelFile)
-import StrongPath.Operations
+import StrongPath.Operations ()
import System.Directory (getFileSize)
-import qualified Wasp.Analyzer as Analyzer
-import qualified Wasp.AppSpec.App as AS.App
-import qualified Wasp.AppSpec.Core.Decl as AS (Decl, takeDecls)
+import qualified Wasp.AppSpec.Valid as ASV
import Wasp.Cli.Command (Command)
-import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd, readWaspCompileInfo)
+import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.Message (cliSendMessageC)
-import qualified Wasp.Cli.Common as Cli.Common
+import Wasp.Cli.Command.Start.Db (getDbSystem)
import Wasp.Cli.Terminal (title)
-import Wasp.Common (WaspProjectDir)
-import Wasp.Error (showCompilerErrorForTerminal)
-import Wasp.Lib (findWaspFile)
import qualified Wasp.Message as Msg
-import Wasp.Util (ifM)
+import Wasp.Project (WaspProjectDir)
import qualified Wasp.Util.IO as IOUtil
import qualified Wasp.Util.Terminal as Term
info :: Command ()
info = do
waspDir <- findWaspProjectRootDirFromCwd
- compileInfo <- liftIO $ readCompileInformation waspDir
+
+ compileInfo <- liftIO $ readWaspCompileInfo waspDir
projectSize <- liftIO $ readDirectorySizeMB waspDir
- declsOrError <- liftIO $ parseWaspFile waspDir
- case declsOrError of
- Left err -> cliSendMessageC $ Msg.Failure "Info failed" err
- Right decls -> do
- cliSendMessageC $
- Msg.Info $
- unlines
- [ "",
- title "Project information",
- printInfo "Name" (fst $ head $ AS.takeDecls @AS.App.App decls),
- printInfo "Last compile" compileInfo,
- printInfo "Project size" projectSize
- ]
+
+ appSpec <- analyze waspDir
+ let (appName, app) = ASV.getApp appSpec
+
+ cliSendMessageC $
+ Msg.Info $
+ unlines
+ [ "",
+ title "Project information",
+ printInfo "Name" appName,
+ printInfo "Database system" $ show $ getDbSystem app,
+ printInfo "Last compile" compileInfo,
+ printInfo "Project dir size" projectSize
+ ]
printInfo :: String -> String -> String
printInfo key value = Term.applyStyles [Term.Cyan] key ++ ": " <> Term.applyStyles [Term.White] value
@@ -52,26 +50,3 @@ readDirectorySizeMB :: Path' Abs (Dir WaspProjectDir) -> IO String
readDirectorySizeMB path = (++ " MB") . show . (`div` 1000000) . sum <$> allFileSizes
where
allFileSizes = IOUtil.listDirectoryDeep path >>= mapM (getFileSize . fromRelFile)
-
-readCompileInformation :: Path' Abs (Dir WaspProjectDir) -> IO String
-readCompileInformation waspDir =
- ifM
- (IOUtil.doesFileExist dotWaspInfoFile)
- (IOUtil.readFile dotWaspInfoFile)
- (return "No compile information found")
- where
- dotWaspInfoFile =
- waspDir > Cli.Common.dotWaspDirInWaspProjectDir
- > Cli.Common.generatedCodeDirInDotWaspDir
- > Cli.Common.dotWaspInfoFileInGeneratedCodeDir
-
-parseWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Either String [AS.Decl])
-parseWaspFile waspDir = runExceptT $ do
- waspFile <- ExceptT $ findWaspFile waspDir
- waspStr <- liftIO $ IOUtil.readFile waspFile
- liftEither $ left (annotateErrorForCli waspFile waspStr) $ Analyzer.analyze waspStr
- where
- annotateErrorForCli waspFile waspStr =
- ("Couldn't parse .wasp file:\n" ++)
- . showCompilerErrorForTerminal (waspFile, waspStr)
- . Analyzer.getErrorMessageAndCtx
diff --git a/waspc/cli/src/Wasp/Cli/Command/Start.hs b/waspc/cli/src/Wasp/Cli/Command/Start.hs
index 7beffda21..83dd41569 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Start.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Start.hs
@@ -15,9 +15,9 @@ import Wasp.Cli.Command.Compile (compile, printWarningsAndErrorsIfAny)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Watch (watch)
import qualified Wasp.Cli.Common as Common
-import Wasp.Lib (CompileError, CompileWarning)
-import qualified Wasp.Lib
+import qualified Wasp.Generator
import qualified Wasp.Message as Msg
+import Wasp.Project (CompileError, CompileWarning)
-- | Does initial compile of wasp code and then runs the generated project.
-- It also listens for any file changes and recompiles and restarts generated project accordingly.
@@ -41,7 +41,7 @@ start = do
-- 'watch') once jobs from 'start' quiet down a bit.
ongoingCompilationResultMVar <- newMVar (warnings, [])
let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
- let startGeneratedWebApp = Wasp.Lib.start outDir (onJobsQuietDown ongoingCompilationResultMVar)
+ let startGeneratedWebApp = Wasp.Generator.start outDir (onJobsQuietDown ongoingCompilationResultMVar)
-- In parallel:
-- 1. watch for any changes in the Wasp project, be it users wasp code or users JS/HTML/...
-- code. On any change, Wasp is recompiled (and generated code is re-generated).
diff --git a/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
new file mode 100644
index 000000000..1f0b9b38e
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
@@ -0,0 +1,177 @@
+module Wasp.Cli.Command.Start.Db
+ ( start,
+ getDbSystem,
+ waspDevDbDockerVolumePrefix,
+ )
+where
+
+import Control.Monad (when)
+import qualified Control.Monad.Except as E
+import Control.Monad.IO.Class (liftIO)
+import Data.Maybe (fromMaybe, isJust)
+import StrongPath (Abs, Dir, File', Path', Rel, fromRelFile)
+import System.Environment (lookupEnv)
+import System.Process (callCommand)
+import Text.Printf (printf)
+import qualified Wasp.AppSpec as AS
+import qualified Wasp.AppSpec.App as AS.App
+import qualified Wasp.AppSpec.App.Db as AS.App.Db
+import qualified Wasp.AppSpec.Valid as ASV
+import Wasp.Cli.Command (Command, CommandError (CommandError))
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd, throwIfExeIsNotAvailable)
+import Wasp.Cli.Command.Compile (analyze)
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import Wasp.Cli.Common (WaspProjectDir)
+import qualified Wasp.Message as Msg
+import Wasp.Project.Db (databaseUrlEnvVarName)
+import Wasp.Project.Db.Dev (makeDevDbUniqueId)
+import qualified Wasp.Project.Db.Dev.Postgres as Dev.Postgres
+import Wasp.Project.Env (dotEnvServer)
+import Wasp.Util (whenM)
+import qualified Wasp.Util.Network.Socket as Socket
+
+-- | Starts a "managed" dev database, where "managed" means that
+-- Wasp creates it and connects the Wasp app with it.
+-- Wasp is smart while doing this so it checks which database is specified
+-- in Wasp configuration and spins up a database of appropriate type.
+start :: Command ()
+start = do
+ waspProjectDir <- findWaspProjectRootDirFromCwd
+ appSpec <- analyze waspProjectDir
+
+ throwIfCustomDbAlreadyInUse appSpec
+
+ let (appName, app) = ASV.getApp appSpec
+ case getDbSystem app of
+ AS.App.Db.SQLite -> noteSQLiteDoesntNeedStart
+ AS.App.Db.PostgreSQL -> startPostgreDevDb waspProjectDir appName
+ where
+ noteSQLiteDoesntNeedStart =
+ cliSendMessageC . Msg.Info $
+ "Nothing to do! You are all good, you are using SQLite which doesn't need to be started."
+
+throwIfCustomDbAlreadyInUse :: AS.AppSpec -> Command ()
+throwIfCustomDbAlreadyInUse spec = do
+ throwIfDbUrlInEnv
+ throwIfDbUrlInServerDotEnv spec
+ where
+ throwIfDbUrlInEnv :: Command ()
+ throwIfDbUrlInEnv = do
+ dbUrl <- liftIO $ lookupEnv databaseUrlEnvVarName
+ when (isJust dbUrl) $
+ throwCustomDbAlreadyInUseError
+ ( "Wasp has detected existing " <> databaseUrlEnvVarName <> " var in your environment.\n"
+ <> "To have Wasp run the dev database for you, make sure you remove that env var first."
+ )
+
+ throwIfDbUrlInServerDotEnv :: AS.AppSpec -> Command ()
+ throwIfDbUrlInServerDotEnv appSpec =
+ when (isThereDbUrlInServerDotEnv appSpec) $
+ throwCustomDbAlreadyInUseError
+ ( printf
+ ( "Wasp has detected that you have defined %s env var in your %s file.\n"
+ <> "To have Wasp run the dev database for you, make sure you remove that env var first."
+ )
+ databaseUrlEnvVarName
+ (fromRelFile (dotEnvServer :: Path' (Rel WaspProjectDir) File'))
+ )
+ where
+ isThereDbUrlInServerDotEnv = any ((== databaseUrlEnvVarName) . fst) . AS.devEnvVarsServer
+
+ throwCustomDbAlreadyInUseError :: String -> Command ()
+ throwCustomDbAlreadyInUseError msg =
+ E.throwError $ CommandError "You are using custom database already" msg
+
+getDbSystem :: AS.App.App -> AS.App.Db.DbSystem
+getDbSystem app =
+ fromMaybe AS.App.Db.SQLite (AS.App.db app >>= AS.App.Db.system)
+
+startPostgreDevDb :: Path' Abs (Dir WaspProjectDir) -> String -> Command ()
+startPostgreDevDb waspProjectDir appName = do
+ throwIfExeIsNotAvailable
+ "docker"
+ "To run PostgreSQL dev database, Wasp needs `docker` installed and in PATH."
+ throwIfDevDbPortIsAlreadyInUse
+
+ cliSendMessageC . Msg.Info $
+ unlines
+ [ "โจ Starting a PostgreSQL dev database (based on your Wasp config) โจ",
+ "",
+ "Additional info:",
+ " โน Connection URL, in case you might want to connect with external tools:",
+ " " <> connectionUrl,
+ " โน Database data is persisted in a docker volume with the following name"
+ <> " (useful to know if you will want to delete it at some point):",
+ " " <> dockerVolumeName
+ ]
+
+ cliSendMessageC $ Msg.Info "..."
+
+ -- NOTE: POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB below are really used by the docker image
+ -- only when initializing the database -> if it already exists, they will be ignored.
+ -- This is how the postgres Docker image works.
+ let command =
+ printf
+ ( unwords
+ [ "docker run",
+ "--name %s",
+ "--rm",
+ "--publish %d:5432",
+ "-v %s:/var/lib/postgresql/data",
+ "--env POSTGRES_PASSWORD=%s",
+ "--env POSTGRES_USER=%s",
+ "--env POSTGRES_DB=%s",
+ "postgres"
+ ]
+ )
+ dockerContainerName
+ Dev.Postgres.defaultDevPort
+ dockerVolumeName
+ Dev.Postgres.defaultDevPass
+ Dev.Postgres.defaultDevUser
+ dbName
+ liftIO $ callCommand command
+ where
+ dockerVolumeName = makeWaspDevDbDockerVolumeName waspProjectDir appName
+ dockerContainerName = makeWaspDevDbDockerContainerName waspProjectDir appName
+ dbName = Dev.Postgres.makeDevDbName waspProjectDir appName
+ connectionUrl = Dev.Postgres.makeDevConnectionUrl waspProjectDir appName
+
+ throwIfDevDbPortIsAlreadyInUse :: Command ()
+ throwIfDevDbPortIsAlreadyInUse = do
+ -- I am checking both conditions because of Docker having virtual network on Mac which
+ -- always gives precedence to native ports so checking only if we can open the port is
+ -- not enough because we can open it even if Docker container is already bound to that port.
+ whenM (liftIO $ Socket.checkIfPortIsInUse devDbSocketAddress) throwPortAlreadyInUseError
+ whenM (liftIO $ Socket.checkIfPortIsAcceptingConnections devDbSocketAddress) throwPortAlreadyInUseError
+ where
+ devDbSocketAddress = Socket.makeLocalHostSocketAddress $ fromIntegral Dev.Postgres.defaultDevPort
+ throwPortAlreadyInUseError =
+ E.throwError $
+ CommandError
+ "Port already in use"
+ ( printf
+ "Wasp can't run PostgreSQL dev database for you since port %d is already in use."
+ Dev.Postgres.defaultDevPort
+ )
+
+-- | Docker volume name unique for the Wasp project with specified path and name.
+makeWaspDevDbDockerVolumeName :: Path' Abs (Dir WaspProjectDir) -> String -> String
+makeWaspDevDbDockerVolumeName waspProjectDir appName =
+ take maxDockerVolumeNameLength $
+ waspDevDbDockerVolumePrefix <> "-" <> makeDevDbUniqueId waspProjectDir appName
+
+waspDevDbDockerVolumePrefix :: String
+waspDevDbDockerVolumePrefix = "wasp-dev-db"
+
+maxDockerVolumeNameLength :: Int
+maxDockerVolumeNameLength = 255
+
+-- | Docker container name unique for the Wasp project with specified path and name.
+makeWaspDevDbDockerContainerName :: Path' Abs (Dir WaspProjectDir) -> String -> String
+makeWaspDevDbDockerContainerName waspProjectDir appName =
+ take maxDockerContainerNameLength $
+ waspDevDbDockerVolumePrefix <> "-" <> makeDevDbUniqueId waspProjectDir appName
+
+maxDockerContainerNameLength :: Int
+maxDockerContainerNameLength = 63
diff --git a/waspc/cli/src/Wasp/Cli/Command/Test.hs b/waspc/cli/src/Wasp/Cli/Command/Test.hs
new file mode 100644
index 000000000..bc263cdbf
--- /dev/null
+++ b/waspc/cli/src/Wasp/Cli/Command/Test.hs
@@ -0,0 +1,48 @@
+module Wasp.Cli.Command.Test
+ ( test,
+ )
+where
+
+import Control.Concurrent.Async (race)
+import Control.Concurrent.MVar (newMVar)
+import Control.Monad.Except (throwError)
+import Control.Monad.IO.Class (liftIO)
+import StrongPath (Abs, Dir, (>))
+import StrongPath.Types (Path')
+import Wasp.Cli.Command (Command, CommandError (..))
+import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
+import Wasp.Cli.Command.Compile (compile)
+import Wasp.Cli.Command.Message (cliSendMessageC)
+import Wasp.Cli.Command.Watch (watch)
+import qualified Wasp.Cli.Common as Common
+import qualified Wasp.Generator
+import Wasp.Generator.Common (ProjectRootDir)
+import qualified Wasp.Message as Msg
+
+test :: [String] -> Command ()
+test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client "
+test ("client" : args) = watchAndTest $ Wasp.Generator.testWebApp args
+test ("server" : _args) = throwError $ CommandError "Invalid arguments" "Server testing not yet implemented."
+test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test client "
+
+watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command ()
+watchAndTest testRunner = do
+ waspRoot <- findWaspProjectRootDirFromCwd
+ let outDir = waspRoot > Common.dotWaspDirInWaspProjectDir > Common.generatedCodeDirInDotWaspDir
+
+ cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..."
+
+ warnings <- compile
+
+ cliSendMessageC $ Msg.Start "Watching for file changes and running tests ..."
+
+ watchOrStartResult <- liftIO $ do
+ ongoingCompilationResultMVar <- newMVar (warnings, [])
+ let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
+ watchWaspProjectSource `race` testRunner outDir
+
+ case watchOrStartResult of
+ Left () -> error "This should never happen, listening for file changes should never end but it did."
+ Right startResult -> case startResult of
+ Left testError -> throwError $ CommandError "Test failed" testError
+ Right () -> return ()
diff --git a/waspc/cli/src/Wasp/Cli/Command/Uninstall.hs b/waspc/cli/src/Wasp/Cli/Command/Uninstall.hs
index 0ee7f7395..85fdf291a 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Uninstall.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Uninstall.hs
@@ -9,6 +9,7 @@ import StrongPath (fromAbsDir, fromAbsFile, (>))
import System.Exit (die)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
+import Wasp.Cli.Command.Start.Db (waspDevDbDockerVolumePrefix)
import Wasp.Cli.FileSystem
( getHomeDir,
getUserCacheDir,
@@ -24,7 +25,12 @@ uninstall :: Command ()
uninstall = do
cliSendMessageC $ Msg.Start "Uninstalling Wasp ..."
liftIO removeWaspFiles
- cliSendMessageC $ Msg.Success "Uninstalled Wasp"
+ cliSendMessageC $ Msg.Success "Uninstalled Wasp."
+ cliSendMessageC $
+ Msg.Info $
+ "If you have used Wasp to run dev database for you, you might want to make sure you also"
+ <> " deleted all the docker volumes it might have created."
+ <> (" You can easily list them by doing `docker volume ls | grep " <> waspDevDbDockerVolumePrefix <> "`.")
removeWaspFiles :: IO ()
removeWaspFiles = do
diff --git a/waspc/cli/src/Wasp/Cli/Command/Watch.hs b/waspc/cli/src/Wasp/Cli/Command/Watch.hs
index 9dd10bb2c..5689e0be5 100644
--- a/waspc/cli/src/Wasp/Cli/Command/Watch.hs
+++ b/waspc/cli/src/Wasp/Cli/Command/Watch.hs
@@ -17,9 +17,9 @@ import qualified System.FilePath as FP
import Wasp.Cli.Command.Compile (compileIO, printCompilationResult)
import qualified Wasp.Cli.Common as Common
import Wasp.Cli.Message (cliSendMessage)
-import Wasp.Lib (CompileError, CompileWarning)
-import qualified Wasp.Lib
+import qualified Wasp.Generator.Common as Wasp.Generator
import qualified Wasp.Message as Msg
+import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
-- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the
-- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to
@@ -32,8 +32,8 @@ import qualified Wasp.Message as Msg
-- (warnings, errors) of the latest (re)compile whenever it happens. If there is already
-- something in the MVar, it will get overwritten.
watch ::
- Path' Abs (Dir Common.WaspProjectDir) ->
- Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
+ Path' Abs (Dir WaspProjectDir) ->
+ Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
MVar ([CompileWarning], [CompileError]) ->
IO ()
watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mgr -> do
diff --git a/waspc/cli/src/Wasp/Cli/Common.hs b/waspc/cli/src/Wasp/Cli/Common.hs
index cece47fef..282cf40df 100644
--- a/waspc/cli/src/Wasp/Cli/Common.hs
+++ b/waspc/cli/src/Wasp/Cli/Common.hs
@@ -18,8 +18,8 @@ where
import StrongPath (Dir, File', Path', Rel, reldir, relfile)
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
-import Wasp.Common (WaspProjectDir)
import qualified Wasp.Generator.Common
+import Wasp.Project (WaspProjectDir)
import qualified Wasp.Util.Terminal as Term
data DotWaspDir -- Here we put everything that wasp generates.
diff --git a/waspc/data/Generator/templates/react-app/public/images/github-logo-icon.png b/waspc/data/Generator/templates/react-app/public/images/github-logo-icon.png
deleted file mode 100644
index 6cb3b705d..000000000
Binary files a/waspc/data/Generator/templates/react-app/public/images/github-logo-icon.png and /dev/null differ
diff --git a/waspc/data/Generator/templates/react-app/public/images/google-logo-icon.png b/waspc/data/Generator/templates/react-app/public/images/google-logo-icon.png
deleted file mode 100644
index a4a9918da..000000000
Binary files a/waspc/data/Generator/templates/react-app/public/images/google-logo-icon.png and /dev/null differ
diff --git a/waspc/data/Generator/templates/react-app/src/actions/_action.js b/waspc/data/Generator/templates/react-app/src/actions/_action.js
deleted file mode 100644
index 1d768b2f6..000000000
--- a/waspc/data/Generator/templates/react-app/src/actions/_action.js
+++ /dev/null
@@ -1,7 +0,0 @@
-{{={= =}=}}
-import { createAction } from './core'
-
-export default createAction(
- '{= actionRoute =}',
- {=& entitiesArray =},
-)
diff --git a/waspc/data/Generator/templates/react-app/src/actions/_action.ts b/waspc/data/Generator/templates/react-app/src/actions/_action.ts
new file mode 100644
index 000000000..57a158956
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/actions/_action.ts
@@ -0,0 +1,10 @@
+{{={= =}=}}
+import { createAction } from './core'
+{=& operationTypeImportStmt =}
+
+const action = createAction<{= operationTypeName =}>(
+ '{= actionRoute =}',
+ {=& entitiesArray =},
+)
+
+export default action
diff --git a/waspc/data/Generator/templates/react-app/src/actions/core.d.ts b/waspc/data/Generator/templates/react-app/src/actions/core.d.ts
index 8a38c25e3..fa31f329f 100644
--- a/waspc/data/Generator/templates/react-app/src/actions/core.d.ts
+++ b/waspc/data/Generator/templates/react-app/src/actions/core.d.ts
@@ -1,3 +1,13 @@
-import { Action } from '.'
+import { type Action } from '.'
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createAction (actionRoute: string, entitiesUsed: unknown[]): Action
+export function createAction(
+ actionRoute: string,
+ entitiesUsed: unknown[]
+): ActionFor
+
+type ActionFor = Expand<
+ Action[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendAction = (args: never, context: any) => unknown
diff --git a/waspc/data/Generator/templates/react-app/src/actions/core.js b/waspc/data/Generator/templates/react-app/src/actions/core.js
index 3258f0d85..440e90614 100644
--- a/waspc/data/Generator/templates/react-app/src/actions/core.js
+++ b/waspc/data/Generator/templates/react-app/src/actions/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
-export function createAction(actionRoute, entitiesUsed) {
+export function createAction(relativeActionRoute, entitiesUsed) {
+ const actionRoute = makeOperationRoute(relativeActionRoute)
+
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
diff --git a/waspc/data/Generator/templates/react-app/src/actions/index.ts b/waspc/data/Generator/templates/react-app/src/actions/index.ts
index 292f8849b..5e4dfedd1 100644
--- a/waspc/data/Generator/templates/react-app/src/actions/index.ts
+++ b/waspc/data/Generator/templates/react-app/src/actions/index.ts
@@ -5,9 +5,12 @@ import {
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
-import { Query } from '../queries';
+import { type Query } from '../queries';
-export type Action = (args?: Input) => Promise;
+export type Action =
+ [Input] extends [never] ?
+ (args?: unknown) => Promise :
+ (args: Input) => Promise
/**
* An options object passed into the `useAction` hook and used to enhance the
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.js b/waspc/data/Generator/templates/react-app/src/api.ts
similarity index 58%
rename from waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.js
rename to waspc/data/Generator/templates/react-app/src/api.ts
index 98238ee21..09d848be6 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.js
+++ b/waspc/data/Generator/templates/react-app/src/api.ts
@@ -1,44 +1,39 @@
-import axios from 'axios'
+import axios, { type AxiosError } from 'axios'
import config from './config'
+import { storage } from './storage'
const api = axios.create({
baseURL: config.apiUrl,
})
-const WASP_APP_AUTH_TOKEN_NAME = "authToken"
+const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
-let authToken = null
-if (window.localStorage) {
- authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
-}
+let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
-export const setAuthToken = (token) => {
- if (typeof token !== 'string') {
- throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
- }
+export function setAuthToken (token: string): void {
authToken = token
- window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
+ storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
}
-export const clearAuthToken = () => {
+export function clearAuthToken(): void {
authToken = undefined
- window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
+ storage.remove(WASP_APP_AUTH_TOKEN_NAME)
}
-export const clearLocalStorage = () => {
+export function removeLocalUserData(): void {
authToken = undefined
- window.localStorage && window.localStorage.clear()
+ storage.clear()
}
-api.interceptors.request.use(request => {
+api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
}
return request
})
-api.interceptors.response.use(undefined, error => {
+api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
}
@@ -50,7 +45,7 @@ api.interceptors.response.use(undefined, error => {
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
-export const handleApiError = (error) => {
+export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
@@ -61,14 +56,23 @@ export const handleApiError = (error) => {
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
- const e = new Error(responseJson?.message || error.message)
- e.statusCode = responseStatusCode
- e.data = responseJson?.data
- throw e
+ throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
+class WaspHttpError extends Error {
+ statusCode: number
+
+ data: unknown
+
+ constructor (statusCode: number, message: string, data: unknown) {
+ super(message)
+ this.statusCode = statusCode
+ this.data = data
+ }
+}
+
export default api
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts
new file mode 100644
index 000000000..c287486ef
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts
@@ -0,0 +1,12 @@
+{{={= =}=}}
+import api, { handleApiError } from '../../../api';
+import { initSession } from '../../helpers/user';
+
+export async function login(data: { email: string; password: string }): Promise {
+ try {
+ const response = await api.post('{= loginPath =}', data);
+ await initSession(response.data.token);
+ } catch (e) {
+ handleApiError(e);
+ }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/passwordReset.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/passwordReset.ts
new file mode 100644
index 000000000..b37cb2761
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/passwordReset.ts
@@ -0,0 +1,20 @@
+{{={= =}=}}
+import api, { handleApiError } from '../../../api';
+
+export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> {
+ try {
+ const response = await api.post('{= requestPasswordResetPath =}', data);
+ return response.data;
+ } catch (e) {
+ handleApiError(e);
+ }
+}
+
+export async function resetPassword(data: { token: string; password: string; }): Promise<{ success: boolean }> {
+ try {
+ const response = await api.post('{= resetPasswordPath =}', data);
+ return response.data;
+ } catch (e) {
+ handleApiError(e);
+ }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/signup.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/signup.ts
new file mode 100644
index 000000000..ad2a37eb8
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/signup.ts
@@ -0,0 +1,11 @@
+{{={= =}=}}
+import api, { handleApiError } from '../../../api';
+
+export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> {
+ try {
+ const response = await api.post('{= signupPath =}', data);
+ return response.data;
+ } catch (e) {
+ handleApiError(e);
+ }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/verifyEmail.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/verifyEmail.ts
new file mode 100644
index 000000000..4c2cfff58
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/verifyEmail.ts
@@ -0,0 +1,13 @@
+{{={= =}=}}
+import api, { handleApiError } from '../../../api'
+
+export async function verifyEmail(data: {
+ token: string
+}): Promise<{ success: boolean; reason?: string }> {
+ try {
+ const response = await api.post('{= verifyEmailPath =}', data)
+ return response.data
+ } catch (e) {
+ handleApiError(e)
+ }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/index.ts b/waspc/data/Generator/templates/react-app/src/auth/email/index.ts
new file mode 100644
index 000000000..ce6dcc6a5
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/email/index.ts
@@ -0,0 +1,5 @@
+export { login } from './actions/login';
+export { signup } from './actions/signup';
+export { requestPasswordReset } from './actions/passwordReset';
+export { resetPassword } from './actions/passwordReset';
+export { verifyEmail } from './actions/verifyEmail';
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx
new file mode 100644
index 000000000..11f85fa45
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx
@@ -0,0 +1,631 @@
+{{={= =}=}}
+import { useState, FormEvent, useEffect } from 'react'
+import { useHistory, useLocation } from 'react-router-dom'
+import { createTheme } from '@stitches/react'
+
+{=# isUsernameAndPasswordAuthEnabled =}
+import signup from '../signup.js'
+import login from '../login.js'
+{=/ isUsernameAndPasswordAuthEnabled =}
+{=# isEmailAuthEnabled =}
+import { signup } from '../email/actions/signup.js'
+import { login } from '../email/actions/login.js'
+import { requestPasswordReset, resetPassword } from '../email/actions/passwordReset.js'
+import { verifyEmail } from '../email/actions/verifyEmail.js'
+{=/ isEmailAuthEnabled =}
+{=# isExternalAuthEnabled =}
+import * as SocialIcons from './SocialIcons'
+import { SocialButton } from './SocialButton';
+{=/ isExternalAuthEnabled =}
+
+import config from '../../config.js'
+import { styled } from '../../stitches.config'
+import { State, CustomizationOptions } from './types'
+
+type ErrorMessage = {
+ title: string;
+ description?: string;
+};
+
+const logoStyle = {
+ height: '3rem'
+}
+
+const Container = styled('div', {
+ display: 'flex',
+ flexDirection: 'column',
+})
+
+const HeaderText = styled('h2', {
+ fontSize: '1.875rem',
+ fontWeight: '700',
+ marginTop: '1.5rem'
+})
+
+const SocialAuth = styled('div', {
+ marginTop: '1.5rem'
+})
+
+const SocialAuthLabel = styled('div', {
+ fontWeight: '500',
+ fontSize: '$sm'
+})
+
+const SocialAuthButtons = styled('div', {
+ marginTop: '0.5rem',
+ display: 'flex',
+
+ variants: {
+ direction: {
+ horizontal: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(48px, 1fr))',
+ },
+ vertical: {
+ flexDirection: 'column',
+ margin: '8px 0',
+ }
+ },
+ gap: {
+ small: {
+ gap: '4px',
+ },
+ medium: {
+ gap: '8px',
+ },
+ large: {
+ gap: '16px',
+ }
+ }
+ }
+})
+
+const OrContinueWith = styled('div', {
+ position: 'relative',
+ marginTop: '1.5rem'
+})
+
+const OrContinueWithLineContainer = styled('div', {
+ position: 'absolute',
+ inset: '0px',
+ display: 'flex',
+ alignItems: 'center'
+})
+
+const OrContinueWithLine = styled('div', {
+ width: '100%',
+ borderTopWidth: '1px',
+ borderColor: '$gray500'
+})
+
+const OrContinueWithTextContainer = styled('div', {
+ position: 'relative',
+ display: 'flex',
+ justifyContent: 'center',
+ fontSize: '$sm'
+})
+
+const OrContinueWithText = styled('span', {
+ backgroundColor: 'white',
+ paddingLeft: '0.5rem',
+ paddingRight: '0.5rem'
+})
+
+// Email/username form
+
+const UserPassForm = styled('form', {
+ marginTop: '1.5rem'
+})
+
+const FormItemGroup = styled('div', {
+ '& + div': {
+ marginTop: '1.5rem'
+ }
+})
+
+const FormLabel = styled('label', {
+ display: 'block',
+ fontSize: '$sm',
+ fontWeight: '500'
+})
+
+const FormInput = styled('input', {
+ display: 'block',
+ lineHeight: '1.5rem',
+ fontSize: '$sm',
+ borderWidth: '1px',
+ borderColor: '$gray600',
+ backgroundColor: '#f8f4ff',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ '&:focus': {
+ borderWidth: '1px',
+ borderColor: '$gray700',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ },
+ '&:disabled': {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ backgroundColor: '$gray400',
+ borderColor: '$gray400',
+ color: '$gray500',
+ },
+
+ borderRadius: '0.375rem',
+ width: '100%',
+
+ paddingTop: '0.375rem',
+ paddingBottom: '0.375rem',
+ paddingLeft: '0.75rem',
+ paddingRight: '0.75rem',
+
+ marginTop: '0.5rem'
+})
+
+const SubmitButton = styled('button', {
+ display: 'flex',
+ justifyContent: 'center',
+
+ width: '100%',
+ borderWidth: '1px',
+ borderColor: '$brand',
+ backgroundColor: '$brand',
+ color: '$submitButtonText',
+
+ padding: '0.5rem 0.75rem',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+
+ fontWeight: '600',
+ fontSize: '$sm',
+ lineHeight: '1.25rem',
+ borderRadius: '0.375rem',
+
+ // TODO(matija): extract this into separate BaseButton component and then inherit it.
+ '&:hover': {
+ backgroundColor: '$brandAccent',
+ borderColor: '$brandAccent',
+ },
+ '&:disabled': {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ backgroundColor: '$gray400',
+ borderColor: '$gray400',
+ color: '$gray500',
+ },
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '100ms'
+})
+
+const Message = styled('div', {
+ padding: '0.5rem 0.75rem',
+ borderRadius: '0.375rem',
+ marginTop: '1rem',
+ background: '$gray400',
+})
+
+const ErrorMessage = styled(Message, {
+ background: '$errorBackground',
+ color: '$errorText',
+})
+
+const SuccessMessage = styled(Message, {
+ background: '$successBackground',
+ color: '$successText',
+})
+
+{=# isGoogleAuthEnabled =}
+const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
+{=/ isGoogleAuthEnabled =}
+{=# isGitHubAuthEnabled =}
+const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
+{=/ isGitHubAuthEnabled =}
+
+function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
+ state: State;
+} & CustomizationOptions) {
+ const isLogin = state === "login";
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ {=# isAnyPasswordBasedAuthEnabled =}
+ const history = useHistory();
+ const onErrorHandler = (error) => {
+ setErrorMessage({ title: error.message, description: error.data?.data?.message })
+ };
+ {=/ isAnyPasswordBasedAuthEnabled =}
+ {=# isUsernameAndPasswordAuthEnabled =}
+ const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({
+ isLogin,
+ onError: onErrorHandler,
+ onSuccess() {
+ // Redirect to configured page, defaults to /.
+ history.push('{= onAuthSucceededRedirectTo =}')
+ },
+ });
+ {=/ isUsernameAndPasswordAuthEnabled =}
+ {=# isEmailAuthEnabled =}
+ const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({
+ isLogin,
+ onError: onErrorHandler,
+ showEmailVerificationPending() {
+ setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
+ },
+ onLoginSuccess() {
+ // Redirect to configured page, defaults to /.
+ history.push('{= onAuthSucceededRedirectTo =}')
+ }
+ });
+ {=/ isEmailAuthEnabled =}
+ {=# isAnyPasswordBasedAuthEnabled =}
+ async function onSubmit (event: FormEvent) {
+ event.preventDefault();
+ setIsLoading(true);
+ setErrorMessage(null);
+ setSuccessMessage(null);
+ try {
+ await handleSubmit();
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ {=/ isAnyPasswordBasedAuthEnabled =}
+
+ // TODO(matija): this is called on every render, is it a problem?
+ // If we do it in useEffect(), then there is a glitch between the default color and the
+ // user provided one.
+ const customTheme = createTheme(appearance ?? {})
+
+ const cta = isLogin ? 'Log in' : 'Sign up'
+ const titles: Record = {
+ login: 'Log in to your account',
+ signup: 'Create a new account',
+ "forgot-password": "Forgot your password?",
+ "reset-password": "Reset your password",
+ "verify-email": "Email verification",
+ }
+ const title = titles[state]
+
+ const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
+
+ const loginSignupForm = (<>
+ {=# isExternalAuthEnabled =}
+
+ {cta} with
+
+ {=# isGoogleAuthEnabled =}
+
+ {=/ isGoogleAuthEnabled =}
+
+ {=# isGitHubAuthEnabled =}
+
+ {=/ isGitHubAuthEnabled =}
+
+
+ {=/ isExternalAuthEnabled =}
+
+ {=# areBothSocialAndPasswordBasedAuthEnabled =}
+
+
+
+
+
+ Or continue with
+
+
+ {=/ areBothSocialAndPasswordBasedAuthEnabled =}
+ {=# isAnyPasswordBasedAuthEnabled =}
+
+ {=# isUsernameAndPasswordAuthEnabled =}
+
+ Username
+ setUsernameFieldVal(e.target.value)}
+ disabled={isLoading}
+ />
+
+ {=/ isUsernameAndPasswordAuthEnabled =}
+ {=# isEmailAuthEnabled =}
+
+ E-mail
+ setEmailFieldVal(e.target.value)}
+ disabled={isLoading}
+ />
+
+ {=/ isEmailAuthEnabled =}
+
+ Password
+ setPasswordFieldVal(e.target.value)}
+ disabled={isLoading}
+ />
+
+
+
+ {cta}
+
+
+ {=/ isAnyPasswordBasedAuthEnabled =}
+ >)
+
+ return (
+
+
+ {logo && (
+
+ )}
+
{title}
+
+
+ {errorMessage && (
+ {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
+ )}
+ {successMessage && {successMessage} }
+ {(state === 'login' || state === 'signup') && loginSignupForm}
+ {=# isEmailAuthEnabled =}
+ {state === 'forgot-password' && ( )}
+ {state === 'reset-password' && ( )}
+ {state === 'verify-email' && ( )}
+ {=/ isEmailAuthEnabled =}
+
+ )
+}
+
+export default Auth;
+
+{=# isEmailAuthEnabled =}
+const ForgotPasswordForm = (
+ { isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
+ isLoading: boolean;
+ setIsLoading: (isLoading: boolean) => void;
+ setErrorMessage: (errorMessage: ErrorMessage | null) => void;
+ setSuccessMessage: (successMessage: string | null) => void;
+ },
+) => {
+ const [email, setEmail] = useState('')
+
+ const onSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ setIsLoading(true)
+ setErrorMessage(null)
+ setSuccessMessage(null)
+ try {
+ await requestPasswordReset({ email })
+ setSuccessMessage('Check your email for a password reset link.')
+ setEmail('')
+ } catch (error) {
+ setErrorMessage({ title: error.message, description: error.data?.data?.message })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <>
+
+
+ E-mail
+ setEmail(e.target.value)}
+ disabled={isLoading}
+ />
+
+
+ Send password reset email
+
+
+ >
+ )
+}
+
+const ResetPasswordForm = (
+ { isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
+ isLoading: boolean;
+ setIsLoading: (isLoading: boolean) => void;
+ setErrorMessage: (errorMessage: ErrorMessage | null) => void;
+ setSuccessMessage: (successMessage: string | null) => void;
+ },
+) => {
+ const location = useLocation()
+ const token = new URLSearchParams(location.search).get('token')
+ const [password, setPassword] = useState('')
+ const [passwordConfirmation, setPasswordConfirmation] = useState('')
+
+ const onSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+
+ if (!token) {
+ setErrorMessage({ title: 'The token is missing from the URL. Please check the link you received in your email.' })
+ return
+ }
+
+ if (!password || password !== passwordConfirmation) {
+ setErrorMessage({ title: `Passwords don't match!` })
+ return
+ }
+
+ setIsLoading(true)
+ setErrorMessage(null)
+ setSuccessMessage(null)
+ try {
+ await resetPassword({ password, token })
+ setSuccessMessage('Your password has been reset.')
+ setPassword('')
+ setPasswordConfirmation('')
+ } catch (error) {
+ setErrorMessage({ title: error.message, description: error.data?.data?.message })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <>
+
+
+ New password
+ setPassword(e.target.value)}
+ disabled={isLoading}
+ />
+
+
+ Confirm new password
+ setPasswordConfirmation(e.target.value)}
+ disabled={isLoading}
+ />
+
+
+ Reset password
+
+
+ >
+ )
+}
+
+const VerifyEmailForm = (
+ { isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
+ isLoading: boolean;
+ setIsLoading: (isLoading: boolean) => void;
+ setErrorMessage: (errorMessage: ErrorMessage | null) => void;
+ setSuccessMessage: (successMessage: string | null) => void;
+ },
+) => {
+ const location = useLocation()
+ const token = new URLSearchParams(location.search).get('token')
+
+ async function submitForm() {
+ if (!token) {
+ setErrorMessage('The token is missing from the URL. Please check the link you received in your email.')
+ return
+ }
+ setIsLoading(true)
+ setErrorMessage(null)
+ setSuccessMessage(null)
+ try {
+ await verifyEmail({ token })
+ setSuccessMessage('Your email has been verified. You can now log in.')
+ } catch (error) {
+ setErrorMessage({ title: error.message, description: error.data?.data?.message })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ submitForm()
+ }, [location])
+
+ return (
+ <>
+ {isLoading && Verifying email... }
+ >
+ )
+}
+{=/ isEmailAuthEnabled =}
+
+{=# isUsernameAndPasswordAuthEnabled =}
+function useUsernameAndPassword({
+ onError,
+ onSuccess,
+ isLogin,
+}: {
+ onError: (error: Error) => void;
+ onSuccess: () => void;
+ isLogin: boolean;
+}) {
+ const [usernameFieldVal, setUsernameFieldVal] = useState('')
+ const [passwordFieldVal, setPasswordFieldVal] = useState('')
+
+ async function handleSubmit () {
+ try {
+ if (!isLogin) {
+ await signup({ username: usernameFieldVal, password: passwordFieldVal })
+ }
+ await login (usernameFieldVal, passwordFieldVal)
+
+ setUsernameFieldVal('')
+ setPasswordFieldVal('')
+ onSuccess()
+ } catch (err: unknown) {
+ onError(err as Error)
+ }
+ }
+
+ return { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal }
+}
+{=/ isUsernameAndPasswordAuthEnabled =}
+{=# isEmailAuthEnabled =}
+function useEmail({
+ onError,
+ showEmailVerificationPending,
+ onLoginSuccess,
+ isLogin,
+}: {
+ onError: (error: Error) => void;
+ showEmailVerificationPending: () => void;
+ onLoginSuccess: () => void;
+ isLogin: boolean;
+}) {
+ const [emailFieldVal, setEmailFieldVal] = useState('')
+ const [passwordFieldVal, setPasswordFieldVal] = useState('')
+
+ async function handleSubmit () {
+ try {
+ if (isLogin) {
+ await login({ email: emailFieldVal, password: passwordFieldVal })
+ onLoginSuccess()
+ } else {
+ await signup({ email: emailFieldVal, password: passwordFieldVal })
+ {=# isEmailVerificationRequired =}
+ showEmailVerificationPending()
+ {=/ isEmailVerificationRequired =}
+ {=^ isEmailVerificationRequired =}
+ await login ({ email: emailFieldVal, password: passwordFieldVal})
+ onLoginSuccess()
+ {=/ isEmailVerificationRequired =}
+ }
+
+ setEmailFieldVal('')
+ setPasswordFieldVal('')
+ } catch (err: unknown) {
+ onError(err as Error)
+ }
+ }
+
+ return { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal }
+}
+{=/ isEmailAuthEnabled =}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/ForgotPassword.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/ForgotPassword.tsx
new file mode 100644
index 000000000..dad842e7c
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/ForgotPassword.tsx
@@ -0,0 +1,13 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function ForgotPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Login.jsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Login.jsx
deleted file mode 100644
index c3bced4a8..000000000
--- a/waspc/data/Generator/templates/react-app/src/auth/forms/Login.jsx
+++ /dev/null
@@ -1,47 +0,0 @@
-{{={= =}=}}
-import React, { useState } from 'react'
-import { useHistory } from 'react-router-dom'
-
-import login from '../login.js'
-import { errorMessage } from '../../utils.js'
-
-const LoginForm = () => {
- const history = useHistory()
-
- const [usernameFieldVal, setUsernameFieldVal] = useState('')
- const [passwordFieldVal, setPasswordFieldVal] = useState('')
-
- const handleLogin = async (event) => {
- event.preventDefault()
- try {
- await login(usernameFieldVal, passwordFieldVal)
- // Redirect to configured page, defaults to /.
- history.push('{= onAuthSucceededRedirectTo =}')
- } catch (err) {
- console.log(err)
- window.alert(errorMessage(err))
- }
- }
-
- return (
-
- )
-}
-
-export default LoginForm
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Login.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Login.tsx
new file mode 100644
index 000000000..7d015510b
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/Login.tsx
@@ -0,0 +1,13 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function LoginForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/ResetPassword.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/ResetPassword.tsx
new file mode 100644
index 000000000..8ac4bef74
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/ResetPassword.tsx
@@ -0,0 +1,13 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function ResetPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.jsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.jsx
deleted file mode 100644
index 2a4ddd182..000000000
--- a/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-{{={= =}=}}
-import React, { useState } from 'react'
-import { useHistory } from 'react-router-dom'
-
-import signup from '../signup.js'
-import login from '../login.js'
-import { errorMessage } from '../../utils.js'
-
-const SignupForm = () => {
- const history = useHistory()
-
- const [usernameFieldVal, setUsernameFieldVal] = useState('')
- const [passwordFieldVal, setPasswordFieldVal] = useState('')
-
- const handleSignup = async (event) => {
- event.preventDefault()
- try {
- await signup({ username: usernameFieldVal, password: passwordFieldVal })
- await login (usernameFieldVal, passwordFieldVal)
-
- setUsernameFieldVal('')
- setPasswordFieldVal('')
-
- // Redirect to configured page, defaults to /.
- history.push('{= onAuthSucceededRedirectTo =}')
- } catch (err) {
- console.log(err)
- window.alert(errorMessage(err))
- }
- }
-
- return (
-
- )
-}
-
-export default SignupForm
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx
new file mode 100644
index 000000000..275885844
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx
@@ -0,0 +1,13 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function SignupForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/SocialButton.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/SocialButton.tsx
new file mode 100644
index 000000000..21de1739e
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/SocialButton.tsx
@@ -0,0 +1,27 @@
+import { styled } from '../../stitches.config'
+
+export const SocialButton = styled('a', {
+ display: 'flex',
+ justifyContent: 'center',
+
+ cursor: 'pointer',
+ // NOTE(matija): icon is otherwise blue, since that
+ // is link's default font color.
+ color: 'inherit',
+ backgroundColor: '#f0f0f0',
+ borderRadius: '0.375rem',
+ borderWidth: '1px',
+ borderColor: '$gray600',
+ fontSize: '13px',
+ padding: '0.5rem 0.75rem',
+ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ '&:visited': {
+ color: 'inherit',
+ },
+ '&:hover': {
+ backgroundColor: '$gray500',
+ color: 'inherit',
+ },
+ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ transitionDuration: '100ms'
+})
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/SocialIcons.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/SocialIcons.tsx
new file mode 100644
index 000000000..a9d13e7dd
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/SocialIcons.tsx
@@ -0,0 +1,34 @@
+import { css } from '@stitches/react'
+
+const defaultStyles = css({
+ width: '1.25rem',
+ height: '1.25rem',
+})
+
+export const Google = () => (
+
+
+
+
+
+
+
+
+)
+
+export const GitHub = () => (
+
+
+
+)
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/VerifyEmail.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/VerifyEmail.tsx
new file mode 100644
index 000000000..ca76aeb8d
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/VerifyEmail.tsx
@@ -0,0 +1,13 @@
+import Auth from './Auth'
+import { type CustomizationOptions, State } from './types'
+
+export function VerifyEmailForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
+ return (
+
+ )
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts
new file mode 100644
index 000000000..560c6930d
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts
@@ -0,0 +1,15 @@
+import { createTheme } from '@stitches/react'
+
+export enum State {
+ Login = "login",
+ Signup = "signup",
+ ForgotPassword = "forgot-password",
+ ResetPassword = "reset-password",
+ VerifyEmail = "verify-email",
+}
+
+export type CustomizationOptions = {
+ logo?: string;
+ socialLayout?: "horizontal" | "vertical";
+ appearance?: Parameters[0];
+}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/helpers/Generic.jsx b/waspc/data/Generator/templates/react-app/src/auth/helpers/Generic.jsx
index 165de0b2e..341465bd4 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/helpers/Generic.jsx
+++ b/waspc/data/Generator/templates/react-app/src/auth/helpers/Generic.jsx
@@ -1,45 +1,15 @@
{{={= =}=}}
import config from '../../config.js'
+import { SocialButton } from '../forms/SocialButton'
+import * as SocialIcons from '../forms/SocialIcons'
export const signInUrl = `${config.apiUrl}{= signInPath =}`
-export const logoUrl = '/images/{= iconName =}'
-
-const containerStyle = {
- border: '2px solid #cbd5e1',
- margin: 0,
- cursor: 'pointer',
- borderRadius: '.375rem',
- backgroundColor: '#f8fafc',
- fontWeight: 600,
- boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
- outline: '2px solid transparent',
- outlineOffset: '2px',
-}
-
-const linkStyle = {
- display: 'flex',
- alignItems: 'center',
- textDecoration: 'none',
- color: '#1e293b',
- paddingLeft: '1.5rem',
- paddingRight: '1.5rem',
- paddingTop: '.75rem',
- paddingBottom: '.75rem',
-}
-
-const logoStyle = {
- maxHeight: '24px',
- marginRight: '0.75rem'
-}
export function SignInButton() {
return (
-
+
+
+
)
}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
index e673cf8d7..1c6fc500f 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
+++ b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts
@@ -1,4 +1,4 @@
-import { setAuthToken } from '../../api.js'
+import { setAuthToken } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(token: string): Promise {
diff --git a/waspc/data/Generator/templates/react-app/src/auth/login.js b/waspc/data/Generator/templates/react-app/src/auth/login.js
index dc0987ea2..7afc28bbd 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/login.js
+++ b/waspc/data/Generator/templates/react-app/src/auth/login.js
@@ -1,10 +1,11 @@
-import api, { handleApiError } from '../api.js'
+{{={= =}=}}
+import api, { handleApiError } from '../api'
import { initSession } from './helpers/user'
export default async function login(username, password) {
try {
const args = { username, password }
- const response = await api.post('/auth/login', args)
+ const response = await api.post('{= loginPath =}', args)
await initSession(response.data.token)
} catch (error) {
diff --git a/waspc/data/Generator/templates/react-app/src/auth/logout.js b/waspc/data/Generator/templates/react-app/src/auth/logout.js
index 3d8ce8414..88236169e 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/logout.js
+++ b/waspc/data/Generator/templates/react-app/src/auth/logout.js
@@ -1,8 +1,8 @@
-import { clearLocalStorage } from '../api.js'
+import { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout() {
- clearLocalStorage()
+ removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
diff --git a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
index 70d640526..363cc2259 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
+++ b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx
@@ -3,7 +3,7 @@ import React, { useEffect } from 'react'
import { useHistory } from 'react-router-dom'
import config from '../../config.js'
-import api from '../../api.js'
+import api from '../../api'
import { initSession } from '../helpers/user'
// After a user authenticates via an Oauth 2.0 provider, this is the page that
diff --git a/waspc/data/Generator/templates/react-app/src/auth/pages/createAuthRequiredPage.jsx b/waspc/data/Generator/templates/react-app/src/auth/pages/createAuthRequiredPage.jsx
index 06e5fbe92..5bc479123 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/pages/createAuthRequiredPage.jsx
+++ b/waspc/data/Generator/templates/react-app/src/auth/pages/createAuthRequiredPage.jsx
@@ -2,7 +2,7 @@
import React from 'react'
import { Redirect } from 'react-router-dom'
-import useAuth from '../useAuth.js'
+import useAuth from '../useAuth'
const createAuthRequiredPage = (Page) => {
diff --git a/waspc/data/Generator/templates/react-app/src/auth/signup.js b/waspc/data/Generator/templates/react-app/src/auth/signup.js
index 0a2e86933..ead2d441b 100644
--- a/waspc/data/Generator/templates/react-app/src/auth/signup.js
+++ b/waspc/data/Generator/templates/react-app/src/auth/signup.js
@@ -1,8 +1,9 @@
-import api, { handleApiError } from '../api.js'
+{{={= =}=}}
+import api, { handleApiError } from '../api'
export default async function signup(userFields) {
try {
- await api.post('/auth/signup', userFields)
+ await api.post('{= signupPath =}', userFields)
} catch (error) {
handleApiError(error)
}
diff --git a/waspc/data/Generator/templates/react-app/src/auth/useAuth.js b/waspc/data/Generator/templates/react-app/src/auth/useAuth.js
deleted file mode 100644
index de32713d1..000000000
--- a/waspc/data/Generator/templates/react-app/src/auth/useAuth.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useQuery } from '../queries'
-import api, { handleApiError } from '../api.js'
-
-export default function useAuth(queryFnArgs, config) {
- return useQuery(getMe, queryFnArgs, config)
-}
-async function getMe() {
- try {
- const response = await api.get('/auth/me')
-
- return response.data
- } catch (error) {
- if (error.response?.status === 401) {
- return null
- } else {
- handleApiError(error)
- }
- }
-}
-
-getMe.queryCacheKey = ['auth/me']
diff --git a/waspc/data/Generator/templates/react-app/src/auth/useAuth.ts b/waspc/data/Generator/templates/react-app/src/auth/useAuth.ts
new file mode 100644
index 000000000..fe5636024
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/auth/useAuth.ts
@@ -0,0 +1,40 @@
+{{={= =}=}}
+import { deserialize as superjsonDeserialize } from 'superjson'
+import { useQuery } from '../queries'
+import api, { handleApiError } from '../api'
+import { HttpMethod } from '../types'
+// todo(filip): turn into a proper import
+import { type SanitizedUser as User } from '../../../server/src/_types/'
+import { addMetadataToQuery } from '../queries/core'
+
+export const getMe = createUserGetter()
+
+export default function useAuth(queryFnArgs?: unknown, config?: any) {
+ return useQuery(getMe, queryFnArgs, config)
+}
+
+function createUserGetter() {
+ const getMeRelativePath = 'auth/me'
+ const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
+ async function getMe(): Promise {
+ try {
+ const response = await api.get(getMeRoute.path)
+
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ if (error.response?.status === 401) {
+ return null
+ } else {
+ handleApiError(error)
+ }
+ }
+ }
+
+ addMetadataToQuery(getMe, {
+ relativeQueryPath: getMeRelativePath,
+ queryRoute: getMeRoute,
+ entitiesUsed: {=& entitiesGetMeDependsOn =},
+ })
+
+ return getMe
+}
diff --git a/waspc/data/Generator/templates/react-app/src/operations/index.js b/waspc/data/Generator/templates/react-app/src/operations/index.js
deleted file mode 100644
index e84d0373a..000000000
--- a/waspc/data/Generator/templates/react-app/src/operations/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import api, { handleApiError } from '../api.js'
-
-export async function callOperation(operationRoute, args) {
- try {
- const response = await api.post(`/${operationRoute}`, args)
- return response.data
- } catch (error) {
- handleApiError(error)
- }
-}
-
diff --git a/waspc/data/Generator/templates/react-app/src/operations/index.ts b/waspc/data/Generator/templates/react-app/src/operations/index.ts
new file mode 100644
index 000000000..2d4494b05
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/operations/index.ts
@@ -0,0 +1,22 @@
+import api, { handleApiError } from '../api'
+import { HttpMethod } from '../types'
+import {
+ serialize as superjsonSerialize,
+ deserialize as superjsonDeserialize,
+ } from 'superjson'
+
+export type OperationRoute = { method: HttpMethod, path: string }
+
+export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
+ try {
+ const superjsonArgs = superjsonSerialize(args)
+ const response = await api.post(operationRoute.path, superjsonArgs)
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
+
+export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
+ return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/operations/resources.js b/waspc/data/Generator/templates/react-app/src/operations/resources.js
index 70c0458a4..779cd55a5 100644
--- a/waspc/data/Generator/templates/react-app/src/operations/resources.js
+++ b/waspc/data/Generator/templates/react-app/src/operations/resources.js
@@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
/**
* Remembers that specified query is using specified resources.
* If called multiple times for same query, resources are added, not reset.
- * @param {string} queryCacheKey - Unique key under used to identify query in the cache.
+ * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
* @param {string[]} resources - Names of resources that query is using.
*/
export function addResourcesUsedByQuery(queryCacheKey, resources) {
diff --git a/waspc/data/Generator/templates/react-app/src/queries/_query.js b/waspc/data/Generator/templates/react-app/src/queries/_query.js
deleted file mode 100644
index e449b5767..000000000
--- a/waspc/data/Generator/templates/react-app/src/queries/_query.js
+++ /dev/null
@@ -1,7 +0,0 @@
-{{={= =}=}}
-import { createQuery } from './core'
-
-export default createQuery(
- '{= queryRoute =}',
- {=& entitiesArray =},
-)
diff --git a/waspc/data/Generator/templates/react-app/src/queries/_query.ts b/waspc/data/Generator/templates/react-app/src/queries/_query.ts
new file mode 100644
index 000000000..c5abe7d9e
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/queries/_query.ts
@@ -0,0 +1,11 @@
+{{={= =}=}}
+import { createQuery } from './core'
+{=& operationTypeImportStmt =}
+
+
+const query = createQuery<{= operationTypeName =}>(
+ '{= queryRoute =}',
+ {=& entitiesArray =},
+)
+
+export default query
diff --git a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
index edde495db..e1bdbe478 100644
--- a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
+++ b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts
@@ -1,3 +1,23 @@
-export type Query = (args: Input) => Promise
+import { type Query } from '.'
+import { Route } from '../types';
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createQuery (queryRoute: string, entitiesUsed: any[]): Query
+export function createQuery(
+ queryRoute: string,
+ entitiesUsed: any[]
+): QueryFor
+
+export function addMetadataToQuery(
+ query: (...args: any[]) => Promise,
+ metadata: {
+ relativeQueryPath: string;
+ queryRoute: Route;
+ entitiesUsed: string[];
+ },
+): void
+
+type QueryFor = Expand<
+ Query[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendQuery = (args: never, context: any) => unknown
diff --git a/waspc/data/Generator/templates/react-app/src/queries/core.js b/waspc/data/Generator/templates/react-app/src/queries/core.js
index bfe2d90c6..5103db1d8 100644
--- a/waspc/data/Generator/templates/react-app/src/queries/core.js
+++ b/waspc/data/Generator/templates/react-app/src/queries/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
addResourcesUsedByQuery,
getActiveOptimisticUpdates,
} from '../operations/resources'
-export function createQuery(queryRoute, entitiesUsed) {
+export function createQuery(relativeQueryPath, entitiesUsed) {
+ const queryRoute = makeOperationRoute(relativeQueryPath)
+
async function query(queryKey, queryArgs) {
const serverResult = await callOperation(queryRoute, queryArgs)
return getActiveOptimisticUpdates(queryKey).reduce(
@@ -13,8 +15,13 @@ export function createQuery(queryRoute, entitiesUsed) {
)
}
- query.queryCacheKey = [queryRoute]
- addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+ addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
return query
}
+
+export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
+ query.queryCacheKey = [relativeQueryPath]
+ query.route = queryRoute
+ addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+}
diff --git a/waspc/data/Generator/templates/react-app/src/queries/index.d.ts b/waspc/data/Generator/templates/react-app/src/queries/index.d.ts
index 3716204db..c007ff4c9 100644
--- a/waspc/data/Generator/templates/react-app/src/queries/index.d.ts
+++ b/waspc/data/Generator/templates/react-app/src/queries/index.d.ts
@@ -1,8 +1,10 @@
import { UseQueryResult } from "@tanstack/react-query";
-export type Query = (args: Input) => Promise
+export type Query = {
+ (queryCacheKey: string[], args: Input): Promise
+}
-export function useQuery (
+export function useQuery (
queryFn: Query ,
queryFnArgs?: Input, options?: any
): UseQueryResult
diff --git a/waspc/data/Generator/templates/react-app/src/stitches.config.js b/waspc/data/Generator/templates/react-app/src/stitches.config.js
new file mode 100644
index 000000000..741c20560
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/stitches.config.js
@@ -0,0 +1,31 @@
+import { createStitches } from '@stitches/react'
+
+export const {
+ styled,
+ css
+} = createStitches({
+ theme: {
+ colors: {
+ waspYellow: '#ffcc00',
+ gray700: '#a1a5ab',
+ gray600: '#d1d5db',
+ gray500: 'gainsboro',
+ gray400: '#f0f0f0',
+ red: '#FED7D7',
+ green: '#C6F6D5',
+
+ brand: '$waspYellow',
+ brandAccent: '#ffdb46',
+ errorBackground: '$red',
+ errorText: '#2D3748',
+ successBackground: '$green',
+ successText: '#2D3748',
+
+ submitButtonText: 'black',
+
+ },
+ fontSizes: {
+ sm: '0.875rem'
+ }
+ }
+})
diff --git a/waspc/data/Generator/templates/react-app/src/storage.ts b/waspc/data/Generator/templates/react-app/src/storage.ts
new file mode 100644
index 000000000..91e039903
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/storage.ts
@@ -0,0 +1,44 @@
+export type DataStore = {
+ set(key: string, value: unknown): void;
+ get(key: string): unknown;
+ remove(key: string): void;
+ clear(): void;
+};
+
+function createLocalStorageDataStore(prefix: string): DataStore {
+ return {
+ set(key, value) {
+ ensureLocalStorageIsAvailable();
+ localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
+ },
+ get(key) {
+ ensureLocalStorageIsAvailable();
+ const value = localStorage.getItem(`${prefix}:${key}`);
+ try {
+ return value ? JSON.parse(value) : undefined;
+ } catch (e: any) {
+ return undefined;
+ }
+ },
+ remove(key) {
+ ensureLocalStorageIsAvailable();
+ localStorage.removeItem(`${prefix}:${key}`);
+ },
+ clear() {
+ ensureLocalStorageIsAvailable();
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith(prefix)) {
+ localStorage.removeItem(key);
+ }
+ });
+ },
+ };
+}
+
+export const storage = createLocalStorageDataStore('wasp');
+
+function ensureLocalStorageIsAvailable(): void {
+ if (!window.localStorage) {
+ throw new Error('Local storage is not available.');
+ }
+}
diff --git a/waspc/data/Generator/templates/react-app/src/test/index.ts b/waspc/data/Generator/templates/react-app/src/test/index.ts
new file mode 100644
index 000000000..f002be83c
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/test/index.ts
@@ -0,0 +1 @@
+export { renderInContext, mockServer } from './vitest/helpers'
diff --git a/waspc/data/Generator/templates/react-app/src/test/vitest/helpers.tsx b/waspc/data/Generator/templates/react-app/src/test/vitest/helpers.tsx
new file mode 100644
index 000000000..655c01a29
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/test/vitest/helpers.tsx
@@ -0,0 +1,93 @@
+import { ReactElement } from 'react'
+import { serialize as superjsonSerialize } from 'superjson'
+import { rest, type ResponseResolver, type RestContext } from 'msw'
+import { setupServer, type SetupServer } from 'msw/node'
+import { BrowserRouter as Router } from 'react-router-dom'
+import { render, RenderResult, cleanup } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { beforeAll, afterEach, afterAll } from 'vitest'
+import { Query } from '../../queries'
+import config from '../../config'
+import { HttpMethod, Route } from '../../types'
+
+export { Route } from '../../types'
+
+export type MockQuery = (
+ query: Query ,
+ resJson: MockOutput
+) => void
+
+export type MockApi = (route: Route, resJson: unknown) => void
+
+// Inspired by the Tanstack React Query helper:
+// https://github.com/TanStack/query/blob/4ae99561ca3383d6de3f4aad656a49ba4a17b57a/packages/react-query/src/__tests__/utils.tsx#L7-L26
+export function renderInContext(ui: ReactElement): RenderResult {
+ const client = new QueryClient()
+ const { rerender, ...result } = render(
+
+ {ui}
+
+ )
+ return {
+ ...result,
+ rerender: (rerenderUi: ReactElement) =>
+ rerender(
+
+ {rerenderUi}
+
+ ),
+ }
+}
+
+export function mockServer(): {
+ server: SetupServer
+ mockQuery: MockQuery
+ mockApi: MockApi
+} {
+ const server: SetupServer = setupServer()
+
+ beforeAll(() => server.listen())
+ afterEach(() => {
+ server.resetHandlers()
+ cleanup()
+ })
+ afterAll(() => server.close())
+
+ const mockQuery: MockQuery = (query, mockData) => {
+ const route = (query as unknown as { route: Route }).route
+ mockRoute(server, route, (_req, res, ctx) =>
+ res(ctx.json(superjsonSerialize(mockData)))
+ )
+ }
+
+ const mockApi: MockApi = (route, mockData) => {
+ mockRoute(server, route, (_req, res, ctx) => res(ctx.json(mockData)))
+ }
+
+ return { server, mockQuery, mockApi }
+}
+
+function mockRoute(
+ server: SetupServer,
+ route: Route,
+ responseHandler: ResponseResolver
+) {
+ if (!Object.values(HttpMethod).includes(route.method)) {
+ throw new Error(
+ `Unsupported query method for mocking: ${
+ route.method
+ }. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`
+ )
+ }
+
+ const url = `${config.apiUrl}${route.path}`
+
+ const handlers: Record[0]> = {
+ [HttpMethod.Get]: rest.get(url, responseHandler),
+ [HttpMethod.Post]: rest.post(url, responseHandler),
+ [HttpMethod.Put]: rest.put(url, responseHandler),
+ [HttpMethod.Delete]: rest.delete(url, responseHandler),
+ }
+
+ server.use(handlers[route.method])
+}
diff --git a/waspc/data/Generator/templates/react-app/src/test/vitest/setup.ts b/waspc/data/Generator/templates/react-app/src/test/vitest/setup.ts
new file mode 100644
index 000000000..d263e51f4
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/test/vitest/setup.ts
@@ -0,0 +1,4 @@
+import matchers from '@testing-library/jest-dom/matchers'
+import { expect } from 'vitest'
+
+expect.extend(matchers)
diff --git a/waspc/data/Generator/templates/react-app/src/types.ts b/waspc/data/Generator/templates/react-app/src/types.ts
new file mode 100644
index 000000000..982b766e3
--- /dev/null
+++ b/waspc/data/Generator/templates/react-app/src/types.ts
@@ -0,0 +1,9 @@
+// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
+export enum HttpMethod {
+ Get = 'GET',
+ Post = 'POST',
+ Put = 'PUT',
+ Delete = 'DELETE',
+}
+
+export type Route = { method: HttpMethod; path: string }
diff --git a/waspc/data/Generator/templates/react-app/tsconfig.json b/waspc/data/Generator/templates/react-app/tsconfig.json
index fdeb6c94b..c5012408c 100644
--- a/waspc/data/Generator/templates/react-app/tsconfig.json
+++ b/waspc/data/Generator/templates/react-app/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "@tsconfig/vite-react/tsconfig.json",
"compilerOptions": {
// Temporary loosen the type checking until we can address all the errors.
+ "jsx": "preserve",
"allowJs": true,
"strict": false
},
diff --git a/waspc/data/Generator/templates/react-app/vite.config.ts b/waspc/data/Generator/templates/react-app/vite.config.ts
index 4948c6fe7..8b3650d21 100644
--- a/waspc/data/Generator/templates/react-app/vite.config.ts
+++ b/waspc/data/Generator/templates/react-app/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
@@ -6,9 +7,15 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
+ host: '0.0.0.0',
+ open: true,
},
envPrefix: 'REACT_APP_',
build: {
outDir: 'build',
},
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/vitest/setup.ts'],
+ },
})
diff --git a/waspc/data/Generator/templates/server/package.json b/waspc/data/Generator/templates/server/package.json
index 80cdb3e85..0b3f962d5 100644
--- a/waspc/data/Generator/templates/server/package.json
+++ b/waspc/data/Generator/templates/server/package.json
@@ -10,8 +10,8 @@
"build-and-start": "npm run build && npm run start",
"watch": "nodemon --exec 'npm run build-and-start || exit 1'",
"validate-env": "node -r dotenv/config ./scripts/validate-env.mjs",
+ "db-seed": "npm run build && NODE_PATH=dist node -r dotenv/config dist/dbSeed.js",
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
- "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
"start-production": "{=& startProductionScript =}",
"standard": "standard",
"postinstall": "patch-package"
@@ -30,6 +30,7 @@
"engines": {
"node": "{=& nodeVersionRange =}"
},
+ "prisma": {=& prisma =},
{=& depsChunk =},
{=& devDepsChunk =}
}
diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/server/src/_types/index.ts
index 9ccef5e1c..f6fb544f4 100644
--- a/waspc/data/Generator/templates/server/src/_types/index.ts
+++ b/waspc/data/Generator/templates/server/src/_types/index.ts
@@ -1,4 +1,7 @@
{{={= =}=}}
+import { type Expand } from "../universal/types.js";
+import { type Request, type Response } from 'express'
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import prisma from "../dbClient.js"
{=# isAuthEnabled =}
import { type {= userEntityName =} } from "../entities"
@@ -20,24 +23,40 @@ export type AuthenticatedAction =
type AuthenticatedOperation = (
args: Input,
- context: Expand & {
- // TODO: This type must match the logic in core/auth.js (if we remove the
- // password field from the object there, we must do the same here). Ideally,
- // these two things would live in the same place:
- // https://github.com/wasp-lang/wasp/issues/965
- {= userFieldName =}: Omit<{= userEntityName =}, 'password'>
- }>,
-) => Promise
+ context: ContextWithUser,
+) => Output | Promise
+
+export type AuthenticatedApi<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: ContextWithUser,
+) => void
{=/ isAuthEnabled =}
type Operation = (
args: Input,
- context: Expand>,
-) => Promise
+ context: Context,
+) => Output | Promise
-type OperationContext = {
- entities: Expand>
-}
+export type Api<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: Context,
+) => void
type EntityMap = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
@@ -49,9 +68,16 @@ type PrismaDelegate = {
{=/ entities =}
}
-// This is a helper type used exclusively for DX purposes. It's a No-op for the
-// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
-// type constructors) to make it more readable for the user.
-//
-// Check this SO answer for details: https://stackoverflow.com/a/57683652
-type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never
+type Context = Expand<{
+ entities: Expand>
+}>
+
+{=# isAuthEnabled =}
+type ContextWithUser = Expand & { user?: SanitizedUser}>
+
+// TODO: This type must match the logic in core/auth.js (if we remove the
+// password field from the object there, we must do the same here). Ideally,
+// these two things would live in the same place:
+// https://github.com/wasp-lang/wasp/issues/965
+export type SanitizedUser = Omit<{= userEntityName =}, 'password'>
+{=/ isAuthEnabled =}
diff --git a/waspc/data/Generator/templates/server/src/actions/_action.js b/waspc/data/Generator/templates/server/src/actions/_action.ts
similarity index 71%
rename from waspc/data/Generator/templates/server/src/actions/_action.js
rename to waspc/data/Generator/templates/server/src/actions/_action.ts
index 309069efd..1bc6c0ad4 100644
--- a/waspc/data/Generator/templates/server/src/actions/_action.js
+++ b/waspc/data/Generator/templates/server/src/actions/_action.ts
@@ -1,13 +1,13 @@
{{={= =}=}}
import prisma from '../dbClient.js'
-{=& jsFnImportStatement =}
+{=& jsFn.importStatement =}
{=! TODO: This template is exactly the same at the moment as one for queries,
consider in the future if it is worth removing this duplication. =}
export default async function (args, context) {
- return {= jsFnIdentifier =}(args, {
+ return ({= jsFn.importIdentifier =} as any)(args, {
...context,
entities: {
{=# entities =}
@@ -16,3 +16,5 @@ export default async function (args, context) {
},
})
}
+
+export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =}
diff --git a/waspc/data/Generator/templates/server/src/apis/types.ts b/waspc/data/Generator/templates/server/src/apis/types.ts
new file mode 100644
index 000000000..bd54b693c
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/apis/types.ts
@@ -0,0 +1,45 @@
+{{={= =}=}}
+
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
+
+export { ParamsDictionary as ExpressParams, Query as ExpressQuery } from 'express-serve-static-core'
+
+import {
+ {=# allEntities =}
+ type {= internalTypeName =},
+ {=/ allEntities =}
+ {=# shouldImportNonAuthenticatedApi =}
+ type Api,
+ {=/ shouldImportNonAuthenticatedApi =}
+ {=# shouldImportAuthenticatedApi =}
+ type AuthenticatedApi,
+ {=/ shouldImportAuthenticatedApi =}
+} from '../_types'
+
+{=# apiRoutes =}
+export type {= typeName =}<
+ P extends ExpressParams = ExpressParams,
+ ResBody = any,
+ ReqBody = any,
+ ReqQuery extends ExpressQuery = ExpressQuery,
+ Locals extends Record = Record
+> =
+ {=# usesAuth =}
+ AuthenticatedApi<
+ {=/ usesAuth =}
+ {=^ usesAuth =}
+ Api<
+ {=/ usesAuth =}
+ [
+ {=# entities =}
+ {= internalTypeName =},
+ {=/ entities =}
+ ],
+ P,
+ ResBody,
+ ReqBody,
+ ReqQuery,
+ Locals
+ >
+
+{=/ apiRoutes =}
diff --git a/waspc/data/Generator/templates/server/src/app.js b/waspc/data/Generator/templates/server/src/app.js
index 7ffcd317d..7d87c21e4 100644
--- a/waspc/data/Generator/templates/server/src/app.js
+++ b/waspc/data/Generator/templates/server/src/app.js
@@ -16,7 +16,7 @@ const app = express()
app.use(helmet())
app.use(cors({
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
- origin: config.frontendUrl,
+ origin: config.allowedCORSOrigins,
}))
app.use(logger('dev'))
app.use(express.json())
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts
new file mode 100644
index 000000000..5d87d8197
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts
@@ -0,0 +1,42 @@
+{{={= =}=}}
+
+import { createRouter } from "../oauth/createRouter.js";
+import { makeOAuthInit } from "../oauth/init.js";
+
+import type { ProviderConfig } from "../types.js";
+import type { OAuthConfig } from "../oauth/types.js";
+
+{=# userFieldsFn.isDefined =}
+{=& userFieldsFn.importStatement =}
+const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =}
+{=/ userFieldsFn.isDefined =}
+{=^ userFieldsFn.isDefined =}
+import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js'
+{=/ userFieldsFn.isDefined =}
+{=# configFn.isDefined =}
+{=& configFn.importStatement =}
+const _waspUserDefinedConfigFn = {= configFn.importIdentifier =}
+{=/ configFn.isDefined =}
+{=^ configFn.isDefined =}
+const _waspUserDefinedConfigFn = undefined
+{=/ configFn.isDefined =}
+
+const _waspOAuthConfig: OAuthConfig = {
+ {=# oAuthConfigProps =}
+ {= key =}: {=& value =},
+ {=/ oAuthConfigProps =}
+};
+
+const _waspConfig: ProviderConfig = {
+ id: "{= providerId =}",
+ displayName: "{= displayName =}",
+ init: makeOAuthInit({
+ npmPackage: '{= npmPackage =}',
+ getUserFieldsFn: _waspGetUserFieldsFn,
+ userDefinedConfigFn: _waspUserDefinedConfigFn,
+ oAuthConfig: _waspOAuthConfig,
+ }),
+ createRouter,
+}
+
+export default _waspConfig;
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts
new file mode 100644
index 000000000..220857004
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts
@@ -0,0 +1,82 @@
+{{={= =}=}}
+import { Router, Request, Response, NextFunction } from "express";
+
+import { ProviderConfig } from "../types.js";
+import type { EmailFromField } from '../../../email/core/types.js';
+
+import { getLoginRoute } from "../email/login.js";
+import { getSignupRoute } from "../email/signup.js";
+import { getRequestPasswordResetRoute } from "../email/requestPasswordReset.js";
+import { resetPassword } from "../email/resetPassword.js";
+import { verifyEmail } from "../email/verifyEmail.js";
+import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from "../email/types.js";
+import { handleRejection } from "../../../utils.js";
+
+{=# getVerificationEmailContent.isDefined =}
+{=& getVerificationEmailContent.importStatement =}
+const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = {= getVerificationEmailContent.importIdentifier =};
+{=/ getVerificationEmailContent.isDefined =}
+{=# getPasswordResetEmailContent.isDefined =}
+{=& getPasswordResetEmailContent.importStatement =}
+const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = {= getPasswordResetEmailContent.importIdentifier =};
+{=/ getPasswordResetEmailContent.isDefined =}
+
+{=^ getVerificationEmailContent.isDefined =}
+const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
+ subject: 'Verify your email',
+ text: `Click the link below to verify your email: ${verificationLink}`,
+ html: `
+ Click the link below to verify your email
+ Verify email
+ `,
+});
+{=/ getVerificationEmailContent.isDefined =}
+{=^ getPasswordResetEmailContent.isDefined =}
+const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
+ subject: 'Reset your password',
+ text: `Click the link below to reset your password: ${passwordResetLink}`,
+ html: `
+ Click the link below to reset your password
+ Reset password
+ `,
+});
+{=/ getPasswordResetEmailContent.isDefined =}
+
+const fromField: EmailFromField = {
+ name: '{= fromField.name =}',
+ email: '{= fromField.email =}',
+};
+
+const config: ProviderConfig = {
+ id: "{= providerId =}",
+ displayName: "{= displayName =}",
+ createRouter() {
+ const router = Router();
+
+ const loginRoute = handleRejection(getLoginRoute({
+ allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =},
+ }));
+ router.post('/login', loginRoute);
+
+ const signupRoute = handleRejection(getSignupRoute({
+ fromField,
+ clientRoute: '{= emailVerificationClientRoute =}',
+ getVerificationEmailContent: _waspGetVerificationEmailContent,
+ }));
+ router.post('/signup', signupRoute);
+
+ const requestPasswordResetRoute = handleRejection(getRequestPasswordResetRoute({
+ fromField,
+ clientRoute: '{= passwordResetClientRoute =}',
+ getPasswordResetEmailContent: _waspGetPasswordResetEmailContent,
+ }));
+ router.post('/request-password-reset', requestPasswordResetRoute);
+
+ router.post('/reset-password', handleRejection(resetPassword));
+ router.post('/verify-email', handleRejection(verifyEmail));
+
+ return router;
+ },
+}
+
+export default config;
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/local.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/local.ts
new file mode 100644
index 000000000..92496bfb1
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/config/local.ts
@@ -0,0 +1,22 @@
+{{={= =}=}}
+
+import { Router } from "express";
+
+import login from "../local/login.js";
+import signup from "../local/signup.js";
+import { ProviderConfig } from "../types.js";
+
+const config: ProviderConfig = {
+ id: "{= providerId =}",
+ displayName: "{= displayName =}",
+ createRouter() {
+ const router = Router();
+
+ router.post('/login', login);
+ router.post('/signup', signup);
+
+ return router;
+ },
+}
+
+export default config;
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts
new file mode 100644
index 000000000..2bc573ae8
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts
@@ -0,0 +1,36 @@
+import { Request, Response } from 'express';
+import { verifyPassword } from "../../../core/auth.js";
+import { findUserBy, createAuthToken, ensureValidEmailAndPassword, throwInvalidCredentialsError } from "../../utils.js";
+
+export function getLoginRoute({
+ allowUnverifiedLogin,
+}: {
+ allowUnverifiedLogin: boolean
+}) {
+ return async function login(
+ req: Request<{ email: string; password: string; }>,
+ res: Response,
+ ): Promise> {
+ const args = req.body || {}
+ ensureValidEmailAndPassword(args)
+
+ args.email = args.email.toLowerCase()
+
+ const user = await findUserBy<'email'>({ email: args.email })
+ if (!user) {
+ throwInvalidCredentialsError()
+ }
+ if (!user.isEmailVerified && !allowUnverifiedLogin) {
+ throwInvalidCredentialsError()
+ }
+ try {
+ await verifyPassword(user.password, args.password);
+ } catch(e) {
+ throwInvalidCredentialsError()
+ }
+
+ const token = await createAuthToken(user)
+
+ return res.json({ token })
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts
new file mode 100644
index 000000000..02eab52a4
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts
@@ -0,0 +1,60 @@
+import { Request, Response } from 'express';
+import {
+ createPasswordResetLink,
+ findUserBy,
+ doFakeWork,
+ ensureValidEmail,
+ sendPasswordResetEmail,
+ isEmailResendAllowed,
+} from "../../utils.js";
+import type { EmailFromField } from '../../../email/core/types.js';
+import { GetPasswordResetEmailContentFn } from './types.js';
+
+export function getRequestPasswordResetRoute({
+ fromField,
+ clientRoute,
+ getPasswordResetEmailContent,
+}: {
+ fromField: EmailFromField;
+ clientRoute: string;
+ getPasswordResetEmailContent: GetPasswordResetEmailContentFn;
+}) {
+ return async function requestPasswordReset(
+ req: Request<{ email: string; }>,
+ res: Response,
+ ): Promise> {
+ const args = req.body || {};
+ ensureValidEmail(args);
+
+ args.email = args.email.toLowerCase();
+
+ const user = await findUserBy<'email'>({ email: args.email });
+
+ // User not found or not verified - don't leak information
+ if (!user || !user.isEmailVerified) {
+ await doFakeWork();
+ return res.json({ success: true });
+ }
+
+ if (!isEmailResendAllowed(user, 'passwordResetSentAt')) {
+ return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
+ }
+
+ const passwordResetLink = await createPasswordResetLink(user, clientRoute);
+ try {
+ await sendPasswordResetEmail(
+ user.email,
+ {
+ from: fromField,
+ to: user.email,
+ ...getPasswordResetEmailContent({ passwordResetLink }),
+ }
+ );
+ } catch (e: any) {
+ console.error("Failed to send password reset email:", e);
+ return res.status(500).json({ success: false, message: "Failed to send password reset email." });
+ }
+
+ res.json({ success: true });
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts
new file mode 100644
index 000000000..0eb6c34df
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts
@@ -0,0 +1,27 @@
+import { Request, Response } from 'express';
+import { ensureValidTokenAndNewPassword, findUserBy, updateUserPassword, verifyToken } from "../../utils.js";
+import { tokenVerificationErrors } from "./types.js";
+
+export async function resetPassword(
+ req: Request<{ token: string; password: string; }>,
+ res: Response,
+): Promise> {
+ const args = req.body || {};
+ ensureValidTokenAndNewPassword(args);
+
+ const { token, password } = args;
+ try {
+ const { id: userId } = await verifyToken(token);
+ const user = await findUserBy<'id'>({ id: userId });
+ if (!user) {
+ return res.status(400).json({ success: false, message: 'Invalid token' });
+ }
+ await updateUserPassword(userId, password);
+ } catch (e) {
+ const reason = e.name === tokenVerificationErrors.TokenExpiredError
+ ? 'expired'
+ : 'invalid';
+ return res.status(400).json({ success: false, message: `Password reset failed, ${reason} token`});
+ }
+ res.json({ success: true });
+};
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts
new file mode 100644
index 000000000..be4994d53
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts
@@ -0,0 +1,64 @@
+import { Request, Response } from 'express';
+import { EmailFromField } from "../../../email/core/types.js";
+import {
+ createEmailVerificationLink,
+ createUser,
+ findUserBy,
+ deleteUser,
+ doFakeWork,
+ ensureValidEmailAndPassword,
+ sendEmailVerificationEmail,
+ isEmailResendAllowed,
+} from "../../utils.js";
+import { GetVerificationEmailContentFn } from './types.js';
+
+export function getSignupRoute({
+ fromField,
+ clientRoute,
+ getVerificationEmailContent,
+}: {
+ fromField: EmailFromField;
+ clientRoute: string;
+ getVerificationEmailContent: GetVerificationEmailContentFn;
+}) {
+ return async function signup(
+ req: Request<{ email: string; password: string; }>,
+ res: Response,
+ ): Promise> {
+ const userFields = req.body;
+ ensureValidEmailAndPassword(userFields);
+
+ userFields.email = userFields.email.toLowerCase();
+
+ const existingUser = await findUserBy<'email'>({ email: userFields.email });
+ // User already exists and is verified - don't leak information
+ if (existingUser && existingUser.isEmailVerified) {
+ await doFakeWork();
+ return res.json({ success: true });
+ } else if (existingUser && !existingUser.isEmailVerified) {
+ if (!isEmailResendAllowed(existingUser, 'emailVerificationSentAt')) {
+ return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
+ }
+ await deleteUser(existingUser);
+ }
+
+ const user = await createUser(userFields);
+
+ const verificationLink = await createEmailVerificationLink(user, clientRoute);
+ try {
+ await sendEmailVerificationEmail(
+ userFields.email,
+ {
+ from: fromField,
+ to: userFields.email,
+ ...getVerificationEmailContent({ verificationLink }),
+ }
+ );
+ } catch (e: any) {
+ console.error("Failed to send email verification email:", e);
+ return res.status(500).json({ success: false, message: "Failed to send email verification email." });
+ }
+
+ return res.json({ success: true });
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts
new file mode 100644
index 000000000..f213c80e9
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts
@@ -0,0 +1,13 @@
+export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent;
+
+export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent;
+
+type EmailContent = {
+ subject: string;
+ html: string;
+ text: string;
+}
+
+export const tokenVerificationErrors = {
+ TokenExpiredError: 'TokenExpiredError',
+};
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts
new file mode 100644
index 000000000..cc930ac99
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts
@@ -0,0 +1,22 @@
+import { Request, Response } from 'express';
+import { updateUserEmailVerification, verifyToken } from '../../utils.js';
+import { tokenVerificationErrors } from './types.js';
+
+export async function verifyEmail(
+ req: Request<{ token: string }>,
+ res: Response,
+): Promise> {
+ try {
+ const { token } = req.body;
+ const { id: userId } = await verifyToken(token);
+ await updateUserEmailVerification(userId);
+ } catch (e) {
+ const reason = e.name === tokenVerificationErrors.TokenExpiredError
+ ? 'expired'
+ : 'invalid';
+ return res.status(400).json({ success: false, message: `Token is ${reason}` });
+ }
+
+ return res.json({ success: true });
+};
+
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/index.ts b/waspc/data/Generator/templates/server/src/auth/providers/index.ts
new file mode 100644
index 000000000..952abdb62
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/index.ts
@@ -0,0 +1,34 @@
+{{={= =}=}}
+
+import { Router } from "express";
+
+import { getDirFromFileUrl, importJsFilesFromDir } from "../../utils.js";
+
+import { ProviderConfig } from "./types";
+
+const allowedConfigs = [
+ {=# enabledProviderIds =}
+ "{= . =}.js",
+ {=/ enabledProviderIds =}
+];
+const providers = await importProviders(allowedConfigs);
+
+const router = Router();
+
+for (const provider of providers) {
+ const { init, createRouter } = provider;
+ const initData = init
+ ? await init(provider)
+ : undefined;
+ const providerRouter = createRouter(provider, initData);
+ router.use(`/${provider.id}`, providerRouter);
+ console.log(`๐ "${provider.displayName}" auth initialized`)
+}
+
+export default router;
+
+async function importProviders(providerConfigs: string[]): Promise {
+ const currentExecutionDir = getDirFromFileUrl(import.meta.url);
+ const providers = await importJsFilesFromDir(currentExecutionDir, "./config", providerConfigs);
+ return providers.map((provider) => provider.default);
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts
new file mode 100644
index 000000000..40140561b
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts
@@ -0,0 +1,30 @@
+{{={= =}=}}
+import { verifyPassword } from '../../../core/auth.js'
+import { handleRejection } from '../../../utils.js'
+
+import { findUserBy, createAuthToken, throwInvalidCredentialsError } from '../../utils.js'
+
+export default handleRejection(async (req, res) => {
+ const args = req.body || {}
+
+ const user = await findUserBy<'username'>({ username: args.username })
+ if (!user) {
+ throwInvalidCredentialsError()
+ }
+
+ try {
+ await verifyPassword(user.password, args.password)
+ } catch(e) {
+ throwInvalidCredentialsError()
+ }
+
+ // Username & password valid - generate token.
+ const token = await createAuthToken(user)
+
+ // NOTE(matija): Possible option - instead of explicitly returning token here,
+ // we could add to response header 'Set-Cookie {token}' directive which would then make
+ // browser automatically save cookie with token.
+ // NOTE(shayne): Cross-domain cookies have serious limitations, which we recently explored.
+
+ return res.json({ token })
+})
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts
new file mode 100644
index 000000000..797e8b6f2
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts
@@ -0,0 +1,11 @@
+{{={= =}=}}
+import { handleRejection } from '../../../utils.js'
+import { createUser } from '../../utils.js'
+
+export default handleRejection(async (req, res) => {
+ const userFields = req.body || {}
+
+ await createUser(userFields)
+
+ return res.json({ success: true })
+})
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts
new file mode 100644
index 000000000..aafb489ac
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts
@@ -0,0 +1,89 @@
+{{={= =}=}}
+
+import { Router } from "express"
+import passport from "passport"
+import { v4 as uuidv4 } from 'uuid'
+
+import prisma from '../../../dbClient.js'
+import waspServerConfig from '../../../config.js'
+import { sign } from '../../../core/auth.js'
+import { authConfig, contextWithUserEntity, createUser } from "../../utils.js"
+
+import type { {= userEntityUpper =} } from '../../../entities';
+import type { ProviderConfig, RequestWithWasp } from "../types.js"
+import type { GetUserFieldsFn } from "./types.js"
+import { handleRejection } from "../../../utils.js"
+
+// For oauth providers, we have an endpoint /login to get the auth URL,
+// and the /callback endpoint which is used to get the actual access_token and the user info.
+export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn: GetUserFieldsFn }) {
+ const { passportStrategyName, getUserFieldsFn } = initData;
+
+ const router = Router();
+
+ // Constructs a provider OAuth URL and redirects browser to start sign in flow.
+ router.get('/login', passport.authenticate(passportStrategyName, { session: false }));
+
+ // Validates the OAuth code from the frontend, via server-to-server communication
+ // with provider. If valid, provides frontend a response containing the JWT.
+ // NOTE: `addProviderProfileToRequest` is invoked as part of the `passport.authenticate`
+ // call, before the final route handler callback. This is how we gain access to `req.wasp.providerProfile`.
+ router.get('/callback',
+ passport.authenticate(passportStrategyName, {
+ session: false,
+ failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
+ }),
+ handleRejection(async function (req: RequestWithWasp, res) {
+ const providerProfile = req?.wasp?.providerProfile;
+
+ if (!providerProfile) {
+ throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
+ } else if (!providerProfile.id) {
+ throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
+ }
+
+ // Wrap call to getUserFieldsFn so we can invoke only if needed.
+ const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
+ // TODO: In the future we could make this configurable, possibly associating an external account
+ // with the currently logged in account, or by some DB lookup.
+ const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
+
+ const token = await sign(user.id);
+ res.json({ token });
+ })
+ )
+
+ return router;
+}
+
+async function findOrCreateUserByExternalAuthAssociation(
+ provider: string,
+ providerId: string,
+ getUserFields: () => ReturnType,
+): Promise<{= userEntityUpper =}> {
+ // Attempt to find a User by an external auth association.
+ const externalAuthAssociation = await prisma.{= externalAuthEntityLower =}.findFirst({
+ where: { provider, providerId },
+ include: { user: true }
+ })
+
+ if (externalAuthAssociation) {
+ return externalAuthAssociation.user
+ }
+
+ // No external auth association linkage found. Create a new User using details from
+ // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User.
+ const userFields = await getUserFields()
+ const userAndExternalAuthAssociation = {
+ ...userFields,
+ {=# isPasswordOnUserEntity =}
+ // TODO: Decouple social from usernameAndPassword auth.
+ password: uuidv4(),
+ {=/ isPasswordOnUserEntity =}
+ externalAuthAssociations: {
+ create: [{ provider, providerId }]
+ }
+ }
+
+ return createUser(userAndExternalAuthAssociation)
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts
new file mode 100644
index 000000000..a6dede224
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts
@@ -0,0 +1,12 @@
+{{={= =}=}}
+import { generateAvailableDictionaryUsername } from '../../../core/auth.js'
+
+export async function getUserFieldsFn(_context, _args) {
+ {=# isUsernameOnUserEntity =}
+ const username = await generateAvailableDictionaryUsername()
+ return { username }
+ {=/ isUsernameOnUserEntity =}
+ {=^ isUsernameOnUserEntity =}
+ return {}
+ {=/ isUsernameOnUserEntity =}
+}
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts
new file mode 100644
index 000000000..3c9105992
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts
@@ -0,0 +1,76 @@
+import passport from "passport";
+
+import waspServerConfig from '../../../config.js';
+
+import type { InitData, ProviderConfig, RequestWithWasp } from "../types.js";
+import type { OAuthConfig, GetUserFieldsFn, UserDefinedConfigFn } from "./types.js";
+
+export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage, oAuthConfig }: OAuthImports) {
+ return async function init(provider: ProviderConfig): Promise {
+ const userDefinedConfig = userDefinedConfigFn
+ ? userDefinedConfigFn()
+ : {};
+ const ProviderStrategy = await import(npmPackage);
+
+ const passportStrategyName = `wasp${provider.id}LoginStrategy`;
+ const requiredConfig = {
+ clientID: oAuthConfig.clientID,
+ clientSecret: oAuthConfig.clientSecret,
+ scope: oAuthConfig.scope,
+ callbackURL: `${waspServerConfig.frontendUrl}/auth/login/${provider.id}`,
+ passReqToCallback: true
+ };
+
+ const config = {
+ ...requiredConfig,
+ ...userDefinedConfig,
+ };
+ ensureValidConfig(provider, config);
+
+ const passportStrategy = new ProviderStrategy.default(
+ config,
+ addProviderProfileToRequest
+ );
+ passport.use(passportStrategyName, passportStrategy);
+
+ return {
+ passportStrategyName,
+ getUserFieldsFn,
+ };
+ }
+}
+
+// This function is invoked after we successfully exchange the one-time-use OAuth code for a real provider API token.
+// This token was used to get the provider profile information supplied as a parameter.
+// We add the provider profile to the request for downstream use.
+async function addProviderProfileToRequest(
+ req: RequestWithWasp,
+ _accessToken: string,
+ _refreshToken: string,
+ providerProfile: { [key: string]: any },
+ done: any,
+) {
+ req.wasp = { ...req.wasp, providerProfile };
+ done(null, {});
+}
+
+function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void {
+ if (!config.clientID) {
+ throw new Error(`The ${provider.displayName} auth provider requires clientID provided via env varibales.`)
+ }
+
+ if (!config.clientSecret) {
+ throw new Error(`The ${provider.displayName} auth provider requires clientSecret provided via env varibales.`)
+ }
+
+ if (!config.scope || !Array.isArray(config.scope)) {
+ throw new Error(`The ${provider.displayName} auth provider requires scope.`)
+ }
+}
+
+export type OAuthImports = {
+ npmPackage: string;
+ userDefinedConfigFn?: UserDefinedConfigFn;
+ getUserFieldsFn: GetUserFieldsFn;
+ oAuthConfig: OAuthConfig;
+};
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts
new file mode 100644
index 000000000..266c28924
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts
@@ -0,0 +1,19 @@
+{{={= =}=}}
+
+import type { Prisma } from "@prisma/client"
+import { contextWithUserEntity } from '../../utils.js'
+
+export type OAuthConfig = {
+ clientID?: string;
+ clientSecret?: string;
+ scope?: string[];
+}
+
+export type CreateOAuthUser = Omit
+
+export type UserDefinedConfigFn = () => { [key: string]: any }
+
+export type GetUserFieldsFn = (
+ context: typeof contextWithUserEntity,
+ args: { profile: { [key: string]: any } },
+) => Promise
diff --git a/waspc/data/Generator/templates/server/src/auth/providers/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/types.ts
new file mode 100644
index 000000000..0718ea5b3
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts
@@ -0,0 +1,19 @@
+import type { Router, Request } from "express"
+
+export type ProviderConfig = {
+ // Unique provider identifier, used as part of URL paths
+ id: string;
+ displayName: string;
+ // Each provider config can have an init method which is ran on setup time
+ // e.g. for oAuth providers this is the time when the Passport strategy is registered.
+ init?(provider: ProviderConfig): Promise;
+ // Every provider must have a setupRouter method which returns the Express router.
+ // In this function we are flexibile to do what ever is necessary to make the provider work.
+ createRouter(provider: ProviderConfig, initData: InitData): Router;
+};
+
+export type InitData = {
+ [key: string]: any;
+}
+
+export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts
new file mode 100644
index 000000000..3479e7482
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/auth/utils.ts
@@ -0,0 +1,225 @@
+{{={= =}=}}
+import { sign, verify } from '../core/auth.js'
+import AuthError from '../core/AuthError.js'
+import HttpError from '../core/HttpError.js'
+import prisma from '../dbClient.js'
+import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js'
+import { type {= userEntityUpper =} } from '../entities/index.js'
+import waspServerConfig from '../config.js';
+import { type Prisma } from '@prisma/client';
+{=# isEmailAuthEnabled =}
+import { isValidEmail } from '../core/auth/validators.js'
+import { emailSender } from '../email/index.js';
+import { Email } from '../email/core/types.js';
+{=/ isEmailAuthEnabled =}
+
+type {= userEntityUpper =}Id = {= userEntityUpper =}['id']
+
+export const contextWithUserEntity = {
+ entities: {
+ {= userEntityUpper =}: prisma.{= userEntityLower =}
+ }
+}
+
+export const authConfig = {
+ failureRedirectPath: "{= failureRedirectPath =}",
+ successRedirectPath: "{= successRedirectPath =}",
+}
+
+export async function findUserBy(where: { [key in K]: {= userEntityUpper =}[K] }): Promise<{= userEntityUpper =}> {
+ return prisma.{= userEntityLower =}.findUnique({ where });
+}
+
+export async function createUser(data: Prisma.{= userEntityUpper =}CreateInput): Promise<{= userEntityUpper =}> {
+ try {
+ return await prisma.{= userEntityLower =}.create({ data })
+ } catch (e) {
+ rethrowPossiblePrismaError(e);
+ }
+}
+
+export async function deleteUser(user: {= userEntityUpper =}): Promise<{= userEntityUpper =}> {
+ try {
+ return await prisma.{= userEntityLower =}.delete({ where: { id: user.id } })
+ } catch (e) {
+ rethrowPossiblePrismaError(e);
+ }
+}
+
+export async function createAuthToken(user: {= userEntityUpper =}): Promise {
+ return sign(user.id);
+}
+
+export async function verifyToken(token: string): Promise<{ id: any }> {
+ return verify(token);
+}
+
+// If an user exists, we don't want to leak information
+// about it. Pretending that we're doing some work
+// will make it harder for an attacker to determine
+// if a user exists or not.
+// NOTE: Attacker measuring time to response can still determine
+// if a user exists or not. We'll be able to avoid it when
+// we implement e-mail sending via jobs.
+export async function doFakeWork() {
+ const timeToWork = Math.floor(Math.random() * 1000) + 1000;
+ return sleep(timeToWork);
+}
+
+{=# isEmailAuthEnabled =}
+export async function updateUserEmailVerification(userId: {= userEntityUpper =}Id): Promise {
+ try {
+ await prisma.{= userEntityLower =}.update({
+ where: { id: userId },
+ data: { isEmailVerified: true },
+ })
+ } catch (e) {
+ rethrowPossiblePrismaError(e);
+ }
+}
+
+export async function updateUserPassword(userId: {= userEntityUpper =}Id, password: string): Promise {
+ try {
+ await prisma.{= userEntityLower =}.update({
+ where: { id: userId },
+ data: { password },
+ })
+ } catch (e) {
+ rethrowPossiblePrismaError(e);
+ }
+}
+
+export async function createEmailVerificationLink(user: {= userEntityUpper =}, clientRoute: string): Promise {
+ const token = await createEmailVerificationToken(user);
+ return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
+}
+
+export async function createPasswordResetLink(user: {= userEntityUpper =}, clientRoute: string): Promise {
+ const token = await createPasswordResetToken(user);
+ return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
+}
+
+async function createEmailVerificationToken(user: {= userEntityUpper =}): Promise {
+ return sign(user.id, { expiresIn: '30m' });
+}
+
+async function createPasswordResetToken(user: {= userEntityUpper =}): Promise {
+ return sign(user.id, { expiresIn: '30m' });
+}
+
+export async function sendPasswordResetEmail(
+ email: string,
+ content: Email,
+): Promise {
+ return sendEmailAndLogTimestamp(email, content, 'passwordResetSentAt');
+}
+
+export async function sendEmailVerificationEmail(
+ email: string,
+ content: Email,
+): Promise {
+ return sendEmailAndLogTimestamp(email, content, 'emailVerificationSentAt');
+}
+
+async function sendEmailAndLogTimestamp(
+ email: string,
+ content: Email,
+ field: 'emailVerificationSentAt' | 'passwordResetSentAt',
+): Promise {
+ // Set the timestamp first, and then send the email
+ // so the user can't send multiple requests while
+ // the email is being sent.
+ try {
+ await prisma.{= userEntityLower =}.update({
+ where: { email },
+ data: { [field]: new Date() },
+ })
+ } catch (e) {
+ rethrowPossiblePrismaError(e);
+ }
+ emailSender.send(content).catch((e) => {
+ console.error(`Failed to send email for ${field}`, e);
+ });
+}
+
+export function isEmailResendAllowed(
+ user: {= userEntityUpper =},
+ field: 'emailVerificationSentAt' | 'passwordResetSentAt',
+ resendInterval: number = 1000 * 60,
+): boolean {
+ const sentAt = user[field];
+ if (!sentAt) {
+ return true;
+ }
+ const now = new Date();
+ const diff = now.getTime() - sentAt.getTime();
+ return diff > resendInterval;
+}
+
+const EMAIL_FIELD = 'email';
+const PASSWORD_FIELD = 'password';
+const TOKEN_FIELD = 'token';
+
+const emailValidators = [
+ { validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
+ { validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) },
+];
+const passwordValidators = [
+ { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
+ { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 },
+ { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) },
+];
+const tokenValidators = [
+ { validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token },
+];
+
+export function ensureValidEmailAndPassword(args: unknown): void {
+ ensureValidEmail(args);
+ ensureValidPassword(args);
+}
+
+export function ensureValidTokenAndNewPassword(args: unknown): void {
+ validate(args, [
+ ...tokenValidators,
+ ]);
+ ensureValidPassword(args);
+}
+
+export function ensureValidEmail(args: unknown): void {
+ validate(args, [
+ ...emailValidators,
+ ]);
+}
+
+export function ensureValidPassword(args: unknown): void {
+ validate(args, [
+ ...passwordValidators,
+ ]);
+}
+
+function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
+ for (const { validates, message, validator } of validators) {
+ if (!validator(args[validates])) {
+ throwValidationError(message);
+ }
+ }
+}
+{=/ isEmailAuthEnabled =}
+
+export function throwInvalidCredentialsError(message?: string): void {
+ throw new HttpError(401, 'Invalid credentials', { message })
+}
+
+function rethrowPossiblePrismaError(e: unknown): void {
+ if (e instanceof AuthError) {
+ throwValidationError(e.message);
+ } else if (isPrismaError(e)) {
+ throw prismaErrorToHttpError(e)
+ } else {
+ throw new HttpError(500)
+ }
+}
+
+function throwValidationError(message: string): void {
+ throw new HttpError(422, 'Validation failed', { message })
+}
\ No newline at end of file
diff --git a/waspc/data/Generator/templates/server/src/config.js b/waspc/data/Generator/templates/server/src/config.js
index ae5c131f1..073492259 100644
--- a/waspc/data/Generator/templates/server/src/config.js
+++ b/waspc/data/Generator/templates/server/src/config.js
@@ -13,25 +13,42 @@ const env = process.env.NODE_ENV || 'development'
const config = {
all: {
env,
+ isDevelopment: env === 'development',
port: parseInt(process.env.PORT) || 3001,
- databaseUrl: process.env.{= databaseUrlEnvVar =},
+ databaseUrl: process.env.{= databaseUrlEnvVarName =},
frontendUrl: undefined,
+ allowedCORSOrigins: [],
{=# isAuthEnabled =}
auth: {
jwtSecret: undefined
}
{=/ isAuthEnabled =}
},
- development: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000',
+ development: getDevelopmentConfig(),
+ production: getProductionConfig(),
+}
+
+const resolvedConfig = merge(config.all, config[env])
+export default resolvedConfig
+
+function getDevelopmentConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000';
+ return {
+ frontendUrl,
+ allowedCORSOrigins: '*',
{=# isAuthEnabled =}
auth: {
jwtSecret: 'DEVJWTSECRET'
}
{=/ isAuthEnabled =}
- },
- production: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL),
+ }
+}
+
+function getProductionConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL);
+ return {
+ frontendUrl,
+ allowedCORSOrigins: [frontendUrl],
{=# isAuthEnabled =}
auth: {
jwtSecret: process.env.JWT_SECRET
@@ -39,6 +56,3 @@ const config = {
{=/ isAuthEnabled =}
}
}
-
-const resolvedConfig = merge(config.all, config[env])
-export default resolvedConfig
diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js
index c248a1d06..74d5d0333 100644
--- a/waspc/data/Generator/templates/server/src/core/auth.js
+++ b/waspc/data/Generator/templates/server/src/core/auth.js
@@ -7,6 +7,7 @@ import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import config from '../config.js'
+import { throwInvalidCredentialsError } from '../auth/utils.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
@@ -33,7 +34,7 @@ const auth = handleRejection(async (req, res, next) => {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
- return res.status(401).send()
+ throwInvalidCredentialsError()
} else {
throw error
}
@@ -41,7 +42,7 @@ const auth = handleRejection(async (req, res, next) => {
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } })
if (!user) {
- return res.status(401).send()
+ throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
@@ -52,7 +53,7 @@ const auth = handleRejection(async (req, res, next) => {
req.user = userView
} else {
- return res.status(401).send()
+ throwInvalidCredentialsError()
}
next()
@@ -66,11 +67,9 @@ export const hashPassword = async (password) => {
}
export const verifyPassword = async (hashedPassword, password) => {
- try {
- return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
- } catch (error) {
- console.error(error)
- return false
+ const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
+ if (result !== SecurePassword.VALID) {
+ throw new Error('Invalid password.')
}
}
diff --git a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js
index 1e3f0d56b..bb71d50f7 100644
--- a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js
+++ b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js
@@ -2,7 +2,9 @@
import { hashPassword } from '../auth.js'
import AuthError from '../AuthError.js'
+{=# isUsernameOnUserEntity =}
const USERNAME_FIELD = 'username'
+{=/ isUsernameOnUserEntity =}
const PASSWORD_FIELD = 'password'
// Allows flexible validation of a user entity.
@@ -59,18 +61,21 @@ export const registerAuthMiddleware = (prismaClient) => {
registerPasswordHashing(prismaClient)
}
+const userValidations = []
+{=# isUsernameOnUserEntity =}
+userValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username })
+{=/ isUsernameOnUserEntity =}
+{=# isPasswordOnUserEntity =}
+userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password })
+userValidations.push({ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 })
+userValidations.push({ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) })
+{=/ isPasswordOnUserEntity =}
+
const validateUser = (user, args, action) => {
user = user || {}
- const defaultValidations = [
- { validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username },
- { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
- { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 },
- { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) },
- ]
-
const validations = [
- ...(args._waspSkipDefaultValidations ? [] : defaultValidations),
+ ...(args._waspSkipDefaultValidations ? [] : userValidations),
...(args._waspCustomValidations || [])
]
diff --git a/waspc/data/Generator/templates/server/src/core/auth/validators.ts b/waspc/data/Generator/templates/server/src/core/auth/validators.ts
new file mode 100644
index 000000000..bfefc5fb7
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/core/auth/validators.ts
@@ -0,0 +1,5 @@
+const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
+
+export function isValidEmail(input: string): boolean {
+ return input.match(validEmailRegex) !== null
+}
diff --git a/waspc/data/Generator/templates/server/src/dbClient.js b/waspc/data/Generator/templates/server/src/dbClient.ts
similarity index 99%
rename from waspc/data/Generator/templates/server/src/dbClient.js
rename to waspc/data/Generator/templates/server/src/dbClient.ts
index e37d7dc4c..fead2ab16 100644
--- a/waspc/data/Generator/templates/server/src/dbClient.js
+++ b/waspc/data/Generator/templates/server/src/dbClient.ts
@@ -2,18 +2,14 @@
import Prisma from '@prisma/client'
{=# isAuthEnabled =}
-
import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js'
-
{=/ isAuthEnabled =}
const createDbClient = () => {
const prismaClient = new Prisma.PrismaClient()
{=# isAuthEnabled =}
-
registerAuthMiddleware(prismaClient)
-
{=/ isAuthEnabled =}
return prismaClient
diff --git a/waspc/data/Generator/templates/server/src/dbSeed.ts b/waspc/data/Generator/templates/server/src/dbSeed.ts
new file mode 100644
index 000000000..a4d89ddd6
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/dbSeed.ts
@@ -0,0 +1,38 @@
+{{={= =}=}}
+
+// This is a script that is used to seed the database. It is present here in the server project in
+// order to have easy access to the server logic, like operations and the rest, since we often want
+// to use that logic in our seeding.
+// TODO: Consider in the future moving it into a a separate project (maybe db/ ?), while still
+// maintaining access to logic from the server/ .
+
+import prismaClient from './dbClient.js'
+import type { DbSeedFn } from './dbSeed/types.js'
+
+{=# dbSeeds =}
+{=& importStatement =}
+{=/ dbSeeds =}
+
+const seeds = {
+ {=# dbSeeds =}
+ {= importIdentifier =},
+ {=/ dbSeeds =}
+}
+
+async function main() {
+ const nameOfSeedToRun = process.env.{= dbSeedNameEnvVarName =}
+ if (nameOfSeedToRun) {
+ console.log(`Running seed: ${nameOfSeedToRun}`)
+ } else {
+ console.error('Name of the seed to run not specified!')
+ }
+ await (seeds[nameOfSeedToRun] satisfies DbSeedFn)(prismaClient)
+}
+
+main()
+ .then(async () => { await prismaClient.$disconnect() })
+ .catch(async (e) => {
+ console.error(e)
+ await prismaClient.$disconnect()
+ process.exit(1)
+ })
diff --git a/waspc/data/Generator/templates/server/src/dbSeed/types.ts b/waspc/data/Generator/templates/server/src/dbSeed/types.ts
new file mode 100644
index 000000000..fe0d5396a
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/dbSeed/types.ts
@@ -0,0 +1,3 @@
+import type { PrismaClient } from '@prisma/client'
+
+export type DbSeedFn = (prismaClient: PrismaClient) => Promise
diff --git a/waspc/data/Generator/templates/server/src/email/core/helpers.ts b/waspc/data/Generator/templates/server/src/email/core/helpers.ts
new file mode 100644
index 000000000..b1c2e911d
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/helpers.ts
@@ -0,0 +1,37 @@
+{{={= =}=}}
+import { EmailFromField } from "./types";
+
+// Formats an email address and an optional name into a string that can be used
+// as the "from" field in an email.
+// { email: "test@test.com, name: "Test" } -> "Test "
+export function formatFromField({
+ email,
+ name,
+}: {
+ email: string;
+ name?: string;
+}): string {
+ if (name) {
+ return `${name} <${email}>`;
+ }
+ return email;
+}
+
+{=# isDefaultFromFieldDefined =}
+export function getDefaultFromField(): EmailFromField {
+ return {
+ email: "{= defaultFromField.email =}",
+ {=# defaultFromField.isNameDefined =}
+ name: "{= defaultFromField.name =}",
+ {=/ defaultFromField.isNameDefined =}
+ }
+}
+{=/ isDefaultFromFieldDefined =}
+{=^ isDefaultFromFieldDefined =}
+export function getDefaultFromField(): EmailFromField {
+ return {
+ email: "",
+ name: "",
+ };
+}
+{=/ isDefaultFromFieldDefined =}
diff --git a/waspc/data/Generator/templates/server/src/email/core/index.ts b/waspc/data/Generator/templates/server/src/email/core/index.ts
new file mode 100644
index 000000000..9844ed396
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/index.ts
@@ -0,0 +1,10 @@
+{{={= =}=}}
+{=# isSmtpProviderUsed =}
+export { initSmtpEmailSender as initEmailSender } from "./providers/smtp.js";
+{=/ isSmtpProviderUsed =}
+{=# isSendGridProviderUsed =}
+export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid.js";
+{=/ isSendGridProviderUsed =}
+{=# isMailgunProviderUsed =}
+export { initMailgunEmailSender as initEmailSender } from "./providers/mailgun.js";
+{=/ isMailgunProviderUsed =}
diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts
new file mode 100644
index 000000000..b4b3ef045
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts
@@ -0,0 +1,24 @@
+import { EmailSender } from "../types.js";
+import { getDefaultFromField } from "../helpers.js";
+
+export function initDummyEmailSender(): EmailSender {
+ const defaultFromField = getDefaultFromField();
+ return {
+ send: async (email) => {
+ const fromField = email.from || defaultFromField;
+ console.log('Test email (not sent):', {
+ from: {
+ email: fromField.email,
+ name: fromField.name,
+ },
+ to: email.to,
+ subject: email.subject,
+ text: email.text,
+ html: email.html,
+ });
+ return {
+ success: true,
+ };
+ }
+ }
+}
diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts b/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts
new file mode 100644
index 000000000..19a8098d5
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts
@@ -0,0 +1,21 @@
+import { NodeMailgun } from "ts-mailgun";
+import { getDefaultFromField } from "../helpers.js";
+import type { MailgunEmailProvider, EmailSender } from "../types.js";
+
+export function initMailgunEmailSender(
+ config: MailgunEmailProvider
+): EmailSender {
+ const mailer = new NodeMailgun(config.apiKey, config.domain);
+
+ const defaultFromField = getDefaultFromField();
+
+ return {
+ async send(email) {
+ const fromField = email.from || defaultFromField;
+ mailer.fromEmail = fromField.email;
+ mailer.fromTitle = fromField.name;
+ mailer.init();
+ return mailer.send(email.to, email.subject, email.html);
+ },
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts b/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts
new file mode 100644
index 000000000..3ce8d39cd
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts
@@ -0,0 +1,27 @@
+import SendGrid from "@sendgrid/mail";
+import { getDefaultFromField } from "../helpers.js";
+import type { SendGridProvider, EmailSender } from "../types.js";
+
+export function initSendGridEmailSender(
+ provider: SendGridProvider
+): EmailSender {
+ SendGrid.setApiKey(provider.apiKey);
+
+ const defaultFromField = getDefaultFromField();
+
+ return {
+ async send(email) {
+ const fromField = email.from || defaultFromField;
+ return SendGrid.send({
+ from: {
+ email: fromField.email,
+ name: fromField.name,
+ },
+ to: email.to,
+ subject: email.subject,
+ text: email.text,
+ html: email.html,
+ });
+ },
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts b/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts
new file mode 100644
index 000000000..b09fbc5ec
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts
@@ -0,0 +1,28 @@
+import { createTransport } from "nodemailer";
+import { formatFromField, getDefaultFromField } from "../helpers.js";
+import type { SMTPEmailProvider, EmailSender } from "../types.js";
+
+export function initSmtpEmailSender(config: SMTPEmailProvider): EmailSender {
+ const transporter = createTransport({
+ host: config.host,
+ port: config.port,
+ auth: {
+ user: config.username,
+ pass: config.password,
+ },
+ });
+
+ const defaultFromField = getDefaultFromField();
+
+ return {
+ async send(email) {
+ return transporter.sendMail({
+ from: formatFromField(email.from || defaultFromField),
+ to: email.to,
+ subject: email.subject,
+ text: email.text,
+ html: email.html,
+ });
+ },
+ };
+}
diff --git a/waspc/data/Generator/templates/server/src/email/core/types.ts b/waspc/data/Generator/templates/server/src/email/core/types.ts
new file mode 100644
index 000000000..a86b26b3b
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/core/types.ts
@@ -0,0 +1,45 @@
+{{={= =}=}}
+export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider;
+
+export type SMTPEmailProvider = {
+ type: "smtp";
+ host: string;
+ port: number;
+ username: string;
+ password: string;
+};
+
+export type SendGridProvider = {
+ type: "sendgrid";
+ apiKey: string;
+};
+
+export type MailgunEmailProvider = {
+ type: "mailgun";
+ apiKey: string;
+ domain: string;
+};
+
+export type EmailSender = {
+ send: (email: Email) => Promise;
+};
+
+export type SentMessageInfo = any;
+
+export type Email = {
+ {=# isDefaultFromFieldDefined =}
+ from?: EmailFromField;
+ {=/ isDefaultFromFieldDefined =}
+ {=^ isDefaultFromFieldDefined =}
+ from: EmailFromField;
+ {=/ isDefaultFromFieldDefined =}
+ to: string;
+ subject: string;
+ text: string;
+ html: string;
+};
+
+export type EmailFromField = {
+ name?: string;
+ email: string;
+}
diff --git a/waspc/data/Generator/templates/server/src/email/index.ts b/waspc/data/Generator/templates/server/src/email/index.ts
new file mode 100644
index 000000000..a4467c43f
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/email/index.ts
@@ -0,0 +1,35 @@
+{{={= =}=}}
+import { initEmailSender } from "./core/index.js";
+
+import waspServerConfig from '../config.js';
+import { initDummyEmailSender } from "./core/providers/dummy.js";
+
+{=# isSmtpProviderUsed =}
+const emailProvider = {
+ type: "smtp",
+ host: process.env.SMTP_HOST,
+ port: parseInt(process.env.SMTP_PORT, 10),
+ username: process.env.SMTP_USERNAME,
+ password: process.env.SMTP_PASSWORD,
+} as const;
+{=/ isSmtpProviderUsed =}
+{=# isSendGridProviderUsed =}
+const emailProvider = {
+ type: "sendgrid",
+ apiKey: process.env.SENDGRID_API_KEY,
+} as const;
+{=/ isSendGridProviderUsed =}
+{=# isMailgunProviderUsed =}
+const emailProvider = {
+ type: "mailgun",
+ apiKey: process.env.MAILGUN_API_KEY,
+ domain: process.env.MAILGUN_DOMAIN,
+} as const;
+{=/ isMailgunProviderUsed =}
+
+const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true";
+const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment;
+
+export const emailSender = isDummyEmailSenderUsed
+ ? initDummyEmailSender()
+ : initEmailSender(emailProvider);
\ No newline at end of file
diff --git a/waspc/data/Generator/templates/server/src/queries/_query.js b/waspc/data/Generator/templates/server/src/queries/_query.ts
similarity index 68%
rename from waspc/data/Generator/templates/server/src/queries/_query.js
rename to waspc/data/Generator/templates/server/src/queries/_query.ts
index aa6c15a3a..2d6c17d48 100644
--- a/waspc/data/Generator/templates/server/src/queries/_query.js
+++ b/waspc/data/Generator/templates/server/src/queries/_query.ts
@@ -1,14 +1,14 @@
{{={= =}=}}
import prisma from '../dbClient.js'
-{=& jsFnImportStatement =}
+{=& jsFn.importStatement =}
{=! TODO: This template is exactly the same at the moment as one for actions,
consider in the future if it is worth removing this duplication. =}
export default async function (args, context) {
- return {= jsFnIdentifier =}(args, {
- ...context,
+ return ({= jsFn.importIdentifier =} as any)(args, {
+ ...context,
entities: {
{=# entities =}
{= name =}: prisma.{= prismaIdentifier =},
@@ -16,3 +16,5 @@ export default async function (args, context) {
},
})
}
+
+export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =}
diff --git a/waspc/data/Generator/templates/server/src/routes/apis/index.ts b/waspc/data/Generator/templates/server/src/routes/apis/index.ts
new file mode 100644
index 000000000..61fe7839f
--- /dev/null
+++ b/waspc/data/Generator/templates/server/src/routes/apis/index.ts
@@ -0,0 +1,43 @@
+{{={= =}=}}
+import express from 'express'
+import prisma from '../../dbClient.js'
+import { handleRejection } from '../../utils.js'
+{=# isAuthEnabled =}
+import auth from '../../core/auth.js'
+import { type SanitizedUser } from '../../_types'
+{=/ isAuthEnabled =}
+
+{=# apiRoutes =}
+{=& importStatement =}
+{=/ apiRoutes =}
+
+const router = express.Router()
+
+{=# apiRoutes =}
+router.{= routeMethod =}(
+ '{= routePath =}',
+ {=# usesAuth =}
+ auth,
+ {=/ usesAuth =}
+ handleRejection(
+ (
+ req: Parameters[0]{=# usesAuth =} & { user: SanitizedUser }{=/ usesAuth =},
+ res: Parameters[1],
+ ) => {
+ const context = {
+ {=# usesAuth =}
+ user: req.user,
+ {=/ usesAuth =}
+ entities: {
+ {=# entities =}
+ {= name =}: prisma.{= prismaIdentifier =},
+ {=/ entities =}
+ },
+ }
+ return {= importIdentifier =}(req, res, context)
+ }
+ )
+)
+{=/ apiRoutes =}
+
+export default router
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/index.js b/waspc/data/Generator/templates/server/src/routes/auth/index.js
index 55436b476..f0feee2cd 100644
--- a/waspc/data/Generator/templates/server/src/routes/auth/index.js
+++ b/waspc/data/Generator/templates/server/src/routes/auth/index.js
@@ -2,22 +2,13 @@
import express from 'express'
import auth from '../../core/auth.js'
-import login from './login.js'
-import signup from './signup.js'
import me from './me.js'
-{=# isExternalAuthEnabled =}
-import passportAuth from './passport/passport.js'
-{=/ isExternalAuthEnabled =}
+import providersRouter from '../../auth/providers/index.js'
const router = express.Router()
-router.post('/login', login)
-router.post('/signup', signup)
router.get('/me', auth, me)
-
-{=# isExternalAuthEnabled =}
-router.use('/external', passportAuth)
-{=/ isExternalAuthEnabled =}
+router.use('/', providersRouter)
export default router
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/login.js b/waspc/data/Generator/templates/server/src/routes/auth/login.js
deleted file mode 100644
index 7825caf03..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/login.js
+++ /dev/null
@@ -1,41 +0,0 @@
-{{={= =}=}}
-import Prisma from '@prisma/client'
-import SecurePassword from 'secure-password'
-
-import { sign, verifyPassword } from '../../core/auth.js'
-import { handleRejection } from '../../utils.js'
-
-const prisma = new Prisma.PrismaClient()
-
-export default handleRejection(async (req, res) => {
- const args = req.body || {}
- const context = {}
-
- // Try to fetch user with the given username.
- const user = await prisma.{= userEntityLower =}.findUnique({ where: { username: args.username } })
- if (!user) {
- return res.status(401).send()
- }
-
- // We got user - now check the password.
- const verifyPassRes = await verifyPassword(user.password, args.password)
- switch (verifyPassRes) {
- case SecurePassword.VALID:
- break
- case SecurePassword.VALID_NEEDS_REHASH:
- // TODO(matija): take neccessary steps to make the password more secure.
- break
- default:
- return res.status(401).send()
- }
-
- // Username & password valid - generate token.
- const token = await sign(user.id)
-
- // NOTE(matija): Possible option - instead of explicitly returning token here,
- // we could add to response header 'Set-Cookie {token}' directive which would then make
- // browser automatically save cookie with token.
- // NOTE(shayne): Cross-domain cookies have serious limitations, which we recently explored.
-
- return res.json({ token })
-})
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/me.js b/waspc/data/Generator/templates/server/src/routes/auth/me.js
index 0e1346c05..18319f1a5 100644
--- a/waspc/data/Generator/templates/server/src/routes/auth/me.js
+++ b/waspc/data/Generator/templates/server/src/routes/auth/me.js
@@ -1,10 +1,12 @@
{{={= =}=}}
+import { serialize as superjsonSerialize } from 'superjson'
import { handleRejection } from '../../utils.js'
+import { throwInvalidCredentialsError } from '../../auth/utils.js'
export default handleRejection(async (req, res) => {
if (req.{= userEntityLower =}) {
- return res.json(req.{= userEntityLower =})
+ return res.json(superjsonSerialize(req.{= userEntityLower =}))
} else {
- return res.status(401).send()
+ throwInvalidCredentialsError()
}
})
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/configMapping.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/configMapping.js
deleted file mode 100644
index 68d5cb9fe..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/configMapping.js
+++ /dev/null
@@ -1,17 +0,0 @@
-{{={= =}=}}
-
-{=# doesConfigFnExist =}
-{=& configFnImportStatement =}
-export { {= configFnIdentifier =} as configFn }
-{=/ doesConfigFnExist =}
-{=^ doesConfigFnExist =}
-export { configFn } from './defaults.js'
-{=/ doesConfigFnExist =}
-
-{=# doesGetUserFieldsFnExist =}
-{=& getUserFieldsFnImportStatement =}
-export { {= getUserFieldsFnIdentifier =} as getUserFieldsFn }
-{=/ doesGetUserFieldsFnExist =}
-{=^ doesGetUserFieldsFnExist =}
-export { getUserFieldsFn } from './defaults.js'
-{=/ doesGetUserFieldsFnExist =}
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/provider.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/provider.js
deleted file mode 100644
index 694a83351..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/generic/provider.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import express from 'express'
-import passport from 'passport'
-
-import waspServerConfig from '../../../../config.js'
-import { contextWithUserEntity, authConfig, findOrCreateUserByExternalAuthAssociation } from '../../utils.js'
-import { sign } from '../../../../core/auth.js'
-
-// This function is invoked after we successfully exchange the one-time-use OAuth code for a real provider API token.
-// This token was used to get the provider profile information supplied as a parameter.
-// We add the provider profile to the request for downstream use.
-async function addProviderProfileToRequest(req, _accessToken, _refreshToken, providerProfile, done) {
- req.wasp = { ...req.wasp, providerProfile }
- done(null, {})
-}
-
-export function initRouter(providerName, ProviderStrategy, config, getUserFieldsFn) {
- // Configure and use Passport.js strategy.
- const passportStrategyName = `wasp${providerName}LoginStrategy`
- const requiredConfig = {
- callbackURL: `${waspServerConfig.frontendUrl}/auth/login/${providerName}`,
- passReqToCallback: true
- }
- const passportStrategy = new ProviderStrategy({ ...config, ...requiredConfig }, addProviderProfileToRequest)
-
- passport.use(passportStrategyName, passportStrategy)
-
- // Create a new router to use the Passport.js strategy.
- const router = express.Router()
-
- // Constructs a provider OAuth URL and redirects browser to start sign in flow.
- router.get('/login', passport.authenticate(passportStrategyName, { session: false }))
-
- // Validates the OAuth code from the frontend, via server-to-server communication
- // with provider. If valid, provides frontend a response containing the JWT.
- // NOTE: `addProviderProfileToRequest` is invoked as part of the `passport.authenticate`
- // call, before the final route handler callback. This is how we gain access to `req.wasp.providerProfile`.
- router.get('/validateCodeForLogin',
- passport.authenticate(passportStrategyName, {
- session: false,
- failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
- }),
- async function (req, res) {
- const providerProfile = req?.wasp?.providerProfile
-
- if (!providerProfile) {
- throw new Error(`Missing ${providerName} provider profile on request. This should not happen! Please contact Wasp.`)
- } else if (!providerProfile.id) {
- throw new Error(`${providerName} provider profile was missing required id property. This should not happen! Please contact Wasp.`)
- }
-
- // Wrap call to getUserFieldsFn so we can invoke only if needed.
- const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile })
- // TODO: In the future we could make this configurable, possibly associating an external account
- // with the currently logged in account, or by some DB lookup.
- const user = await findOrCreateUserByExternalAuthAssociation(providerName, providerProfile.id, getUserFields)
-
- const token = await sign(user.id)
- res.json({ token })
- })
-
- return router
-}
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/github/config.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/github/config.js
deleted file mode 100644
index 3daa220d4..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/github/config.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { configFn } from './configMapping.js'
-export { getUserFieldsFn } from './configMapping.js'
-
-// Validates the provided config function returns all required data.
-export const config = ((config) => {
- if (!config?.clientID) {
- throw new Error("The GitHub configFn must return an object with a clientID property.")
- }
-
- if (!config?.clientSecret) {
- throw new Error("The GitHub configFn must return an object with a clientSecret property.")
- }
-
- if (!config?.scope || !Array.isArray(config.scope)) {
- throw new Error("The GitHub configFn must return an object with a scope property.")
- }
-
- return config
-})(await configFn())
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/github/defaults.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/github/defaults.js
deleted file mode 100644
index 23bea83e7..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/github/defaults.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { generateAvailableDictionaryUsername } from '../../../../core/auth.js'
-
-// Default implementation if there is no `auth.methods.gitHub.configFn`.
-export function configFn() {
- const clientID = process.env['GITHUB_CLIENT_ID']
- const clientSecret = process.env['GITHUB_CLIENT_SECRET']
-
- if (!clientID) {
- throw new Error("Missing GITHUB_CLIENT_ID environment variable.")
- }
-
- if (!clientSecret) {
- throw new Error("Missing GITHUB_CLIENT_SECRET environment variable.")
- }
-
- return { clientID, clientSecret, scope: [] }
-}
-
-// Default implementation if there is no `auth.methods.gitHub.getUserFieldsFn`.
-export async function getUserFieldsFn(_context, _args) {
- const username = await generateAvailableDictionaryUsername()
- return { username }
-}
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/config.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/config.js
deleted file mode 100644
index c03b2488d..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/config.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { configFn } from './configMapping.js'
-export { getUserFieldsFn } from './configMapping.js'
-
-// Validates the provided config function returns all required data.
-export const config = ((config) => {
- if (!config?.clientID) {
- throw new Error("The Google configFn must return an object with a clientID property.")
- }
-
- if (!config?.clientSecret) {
- throw new Error("The Google configFn must return an object with a clientSecret property.")
- }
-
- if (!config?.scope) {
- throw new Error("The Google configFn must return an object with a scope property.")
- } else if (!Array.isArray(config.scope) || !config.scope.includes('profile')) {
- throw new Error("The Google configFn returned an object with an invalid scope property. It must be an array including 'profile'.")
- }
-
- return config
-})(await configFn())
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/defaults.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/defaults.js
deleted file mode 100644
index 46706a509..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/defaults.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { generateAvailableDictionaryUsername } from '../../../../core/auth.js'
-
-// Default implementation if there is no `auth.methods.google.configFn`.
-export function configFn() {
- const clientID = process.env['GOOGLE_CLIENT_ID']
- const clientSecret = process.env['GOOGLE_CLIENT_SECRET']
-
- if (!clientID) {
- throw new Error("Missing GOOGLE_CLIENT_ID environment variable.")
- }
-
- if (!clientSecret) {
- throw new Error("Missing GOOGLE_CLIENT_SECRET environment variable.")
- }
-
- return { clientID, clientSecret, scope: ['profile'] }
-}
-
-// Default implementation if there is no `auth.methods.google.getUserFieldsFn`.
-export async function getUserFieldsFn(_context, _args) {
- const username = await generateAvailableDictionaryUsername()
- return { username }
-}
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js
deleted file mode 100644
index 09c9965ab..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js
+++ /dev/null
@@ -1,24 +0,0 @@
-{{={= =}=}}
-import express from 'express'
-import { initRouter } from './generic/provider.js'
-
-const providerMap = new Map();
-{=# providers =}
-{=# isEnabled =}
-providerMap.set('{= slug =}', { npmPackage: '{= npmPackage =}', passportImportPath: '{= passportImportPath =}' })
-{=/ isEnabled =}
-{=/ providers =}
-
-const router = express.Router()
-
-async function initProviders(providers) {
- for (let [providerSlug, { npmPackage, passportImportPath }] of providers) {
- const { config, getUserFieldsFn } = await import(passportImportPath)
- const ProviderStrategy = await import(npmPackage)
- router.use(`/${providerSlug}`, initRouter(providerSlug, ProviderStrategy.default, config, getUserFieldsFn))
- }
-}
-
-await initProviders(providerMap)
-
-export default router
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/signup.js b/waspc/data/Generator/templates/server/src/routes/auth/signup.js
deleted file mode 100644
index 9b4ac3774..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/signup.js
+++ /dev/null
@@ -1,23 +0,0 @@
-{{={= =}=}}
-import prisma from '../../dbClient.js'
-import { handleRejection, isPrismaError, prismaErrorToHttpError } from '../../utils.js'
-import AuthError from '../../core/AuthError.js'
-import HttpError from '../../core/HttpError.js'
-
-export default handleRejection(async (req, res) => {
- const userFields = req.body || {}
-
- try {
- await prisma.{= userEntityLower =}.create({ data: userFields })
- } catch (e) {
- if (e instanceof AuthError) {
- throw new HttpError(422, 'Validation failed', { message: e.message })
- } else if (isPrismaError(e)) {
- throw prismaErrorToHttpError(e)
- } else {
- throw new HttpError(500)
- }
- }
-
- res.send()
-})
diff --git a/waspc/data/Generator/templates/server/src/routes/auth/utils.js b/waspc/data/Generator/templates/server/src/routes/auth/utils.js
deleted file mode 100644
index ffa9fe77f..000000000
--- a/waspc/data/Generator/templates/server/src/routes/auth/utils.js
+++ /dev/null
@@ -1,42 +0,0 @@
-{{={= =}=}}
-
-import { v4 as uuidv4 } from 'uuid'
-
-import prisma from '../../dbClient.js'
-
-export const contextWithUserEntity = {
- entities: {
- {= userEntityUpper =}: prisma.{= userEntityLower =}
- }
-}
-
-export const authConfig = {
- failureRedirectPath: "{= failureRedirectPath =}",
- successRedirectPath: "{= successRedirectPath =}",
-}
-
-export async function findOrCreateUserByExternalAuthAssociation(provider, providerId, getUserFields) {
- // Attempt to find a User by an external auth association.
- const externalAuthAssociation = await prisma.{= externalAuthEntityLower =}.findFirst({
- where: { provider, providerId },
- include: { user: true }
- })
-
- if (externalAuthAssociation) {
- return externalAuthAssociation.user
- }
-
- // No external auth association linkage found. Create a new User using details from
- // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User.
- // NOTE: For now, we force a random (uuidv4) password string. In the future, we will allow password reset.
- const userFields = await getUserFields()
- const userAndExternalAuthAssociation = {
- ...userFields,
- password: uuidv4(),
- externalAuthAssociations: {
- create: [{ provider, providerId }]
- }
- }
-
- return await prisma.{= userEntityLower =}.create({ data: userAndExternalAuthAssociation })
-}
diff --git a/waspc/data/Generator/templates/server/src/routes/index.js b/waspc/data/Generator/templates/server/src/routes/index.js
index af9104c64..fa89ea810 100644
--- a/waspc/data/Generator/templates/server/src/routes/index.js
+++ b/waspc/data/Generator/templates/server/src/routes/index.js
@@ -4,6 +4,9 @@ import operations from './operations/index.js'
{=# isAuthEnabled =}
import auth from './auth/index.js'
{=/ isAuthEnabled =}
+{=# areThereAnyCustomApiRoutes =}
+import apis from './apis/index.js'
+{=/ areThereAnyCustomApiRoutes =}
const router = express.Router()
@@ -16,5 +19,9 @@ router.get('/', function (req, res, next) {
router.use('/auth', auth)
{=/ isAuthEnabled =}
router.use('/{= operationsRouteInRootRouter =}', operations)
+{=# areThereAnyCustomApiRoutes =}
+// Keep user-defined api routes last so they cannot override our routes.
+router.use(apis)
+{=/ areThereAnyCustomApiRoutes =}
export default router
diff --git a/waspc/data/Generator/templates/server/src/routes/operations/_action.js b/waspc/data/Generator/templates/server/src/routes/operations/_action.js
index 299b07230..726db48b7 100644
--- a/waspc/data/Generator/templates/server/src/routes/operations/_action.js
+++ b/waspc/data/Generator/templates/server/src/routes/operations/_action.js
@@ -1,17 +1,22 @@
{{={= =}=}}
+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 || {}
+ const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
{=# userEntityLower =}
user: req.user
{=/ userEntityLower =}
}
+
const result = await {= operationName =}(args, context)
- res.json(result)
+ const serializedResult = superjsonSerialize(result)
+ res.json(serializedResult)
})
diff --git a/waspc/data/Generator/templates/server/src/routes/operations/_query.js b/waspc/data/Generator/templates/server/src/routes/operations/_query.js
index 26b56c2d3..9ec8b15e0 100644
--- a/waspc/data/Generator/templates/server/src/routes/operations/_query.js
+++ b/waspc/data/Generator/templates/server/src/routes/operations/_query.js
@@ -1,6 +1,9 @@
{{={= =}=}}
+import {
+ deserialize as superjsonDeserialize,
+ serialize as superjsonSerialize,
+} from 'superjson'
import { handleRejection } from '../../utils.js'
-
{=& operationImportStmt =}
export default handleRejection(async (req, res) => {
@@ -11,7 +14,7 @@ export default handleRejection(async (req, res) => {
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 || {}
+ const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
{=# userEntityLower =}
@@ -20,5 +23,6 @@ export default handleRejection(async (req, res) => {
}
const result = await {= operationName =}(args, context)
- res.json(result)
+ const serializedResult = superjsonSerialize(result)
+ res.json(serializedResult)
})
diff --git a/waspc/data/Generator/templates/server/src/server.ts b/waspc/data/Generator/templates/server/src/server.ts
index 4b53931ef..cb4d0cc47 100644
--- a/waspc/data/Generator/templates/server/src/server.ts
+++ b/waspc/data/Generator/templates/server/src/server.ts
@@ -4,10 +4,10 @@ import http from 'http'
import app from './app.js'
import config from './config.js'
-{=# doesServerSetupFnExist =}
-{=& serverSetupJsFnImportStatement =}
+{=# setupFn.isDefined =}
+{=& setupFn.importStatement =}
import { ServerSetupFn, ServerSetupFnContext } from './types'
-{=/ doesServerSetupFnExist =}
+{=/ setupFn.isDefined =}
{=# isPgBossJobExecutorUsed =}
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
@@ -24,10 +24,10 @@ const startServer = async () => {
const server = http.createServer(app)
- {=# doesServerSetupFnExist =}
+ {=# setupFn.isDefined =}
const serverSetupFnContext: ServerSetupFnContext = { app, server }
- await ({= serverSetupJsFnIdentifier =} as ServerSetupFn)(serverSetupFnContext)
- {=/ doesServerSetupFnExist =}
+ await ({= setupFn.importIdentifier =} as ServerSetupFn)(serverSetupFnContext)
+ {=/ setupFn.isDefined =}
server.listen(port)
diff --git a/waspc/data/Generator/templates/server/src/types/index.ts b/waspc/data/Generator/templates/server/src/types/index.ts
index 994904589..cc80863c6 100644
--- a/waspc/data/Generator/templates/server/src/types/index.ts
+++ b/waspc/data/Generator/templates/server/src/types/index.ts
@@ -1,4 +1,6 @@
-import { Application } from 'express'
+{{={= =}=}}
+
+import { type Application } from 'express'
import { Server } from 'http'
export type ServerSetupFn = (context: ServerSetupFnContext) => Promise
@@ -8,5 +10,13 @@ export type ServerSetupFnContext = {
server: Server,
}
-export { Application } from 'express'
+export type { Application } from 'express'
export { Server } from 'http'
+
+{=# isExternalAuthEnabled =}
+export type { GetUserFieldsFn } from '../auth/providers/oauth/types';
+{=/ isExternalAuthEnabled =}
+
+{=# isEmailAuthEnabled =}
+export type { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from '../auth/providers/email/types';
+{=/ isEmailAuthEnabled =}
diff --git a/waspc/data/Generator/templates/server/src/utils.js b/waspc/data/Generator/templates/server/src/utils.js
index e07b121be..d6c7ed639 100644
--- a/waspc/data/Generator/templates/server/src/utils.js
+++ b/waspc/data/Generator/templates/server/src/utils.js
@@ -1,6 +1,10 @@
import Prisma from '@prisma/client'
import HttpError from './core/HttpError.js'
+import { readdir } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from 'url';
+
/**
* Decorator for async express middleware that handles promise rejections.
* @param {Func} middleware - Express middleware function.
@@ -28,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
- message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
+ message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {
@@ -45,3 +49,31 @@ export const prismaErrorToHttpError = (e) => {
}
export const sleep = ms => new Promise(r => setTimeout(r, ms))
+
+export function getDirFromFileUrl(fileUrl) {
+ return fileURLToPath(dirname(fileUrl));
+}
+
+export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) {
+ const pathToDir = join(absoluteDir, relativePath);
+
+ return new Promise((resolve, reject) => {
+ readdir(pathToDir, async (err, files) => {
+ if (err) {
+ return reject(err);
+ }
+ const importPromises = files
+ .filter((file) => file.endsWith(".js") && isWhitelisted(file))
+ .map((file) => import(`${pathToDir}/${file}`));
+ resolve(Promise.all(importPromises));
+ });
+ });
+
+ function isWhitelisted(file) {
+ // No whitelist means all files are whitelisted
+ if (!Array.isArray(whitelist)) {
+ return true;
+ }
+ return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile));
+ }
+}
diff --git a/waspc/data/Generator/templates/server/tsconfig.json b/waspc/data/Generator/templates/server/tsconfig.json
index 562d7839c..8999362fe 100644
--- a/waspc/data/Generator/templates/server/tsconfig.json
+++ b/waspc/data/Generator/templates/server/tsconfig.json
@@ -5,6 +5,7 @@
"strict": false,
// Overriding this because we want to use top-level await
"module": "esnext",
+ "target": "es2017",
// Enable source map for debugging
"sourceMap": true,
// The remaining settings should match node18/tsconfig.json, but I kept
diff --git a/waspc/data/Generator/templates/universal/types.ts b/waspc/data/Generator/templates/universal/types.ts
new file mode 100644
index 000000000..8cadbd740
--- /dev/null
+++ b/waspc/data/Generator/templates/universal/types.ts
@@ -0,0 +1,31 @@
+// This is a helper type used exclusively for DX purposes. It's a No-op for the
+// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
+// type constructors) to make it more readable for the user.
+//
+// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
+export type Expand = T extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : T extends infer O
+ ? { [K in keyof O]: O[K] }
+ : never
+
+// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
+// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
+// details). Until it's fixed, we're using our own type for this.
+//
+// TODO: investigate further. This most likely has something to do with an
+// unsatisfied 'extends' constraints. A mismatch is probably happening with
+// function parameter types and/or return types (check '_ReturnType' below for
+// more).
+export type _Awaited = T extends Promise
+ ? _Awaited
+ : T
+
+// TypeScript's native ReturnType does not work for functions of type '(...args:
+// never[]) => unknown' (and that's what operations currently use).
+//
+// TODO: investigate how to properly specify the 'extends' constraint for function
+// type (i.e., any vs never and unknown) and stick with that. Take DX into
+// consideration.
+export type _ReturnType unknown> =
+ T extends (...args: never[]) => infer R ? R : never
diff --git a/waspc/dev-tool.project b/waspc/dev-tool.project
index c839cc780..de4a64415 100644
--- a/waspc/dev-tool.project
+++ b/waspc/dev-tool.project
@@ -12,4 +12,4 @@ constraints:
prune-juice ==0.7
-- See cabal.project.
-index-state: 2022-03-22T14:16:26Z
+index-state: 2023-03-09T09:15:49Z
diff --git a/waspc/docs/design-docs/db-seeding.org b/waspc/docs/design-docs/db-seeding.org
new file mode 100644
index 000000000..18807986b
--- /dev/null
+++ b/waspc/docs/design-docs/db-seeding.org
@@ -0,0 +1,149 @@
+** Requirements
+
+Relevant Github issue: https://github.com/wasp-lang/wasp/issues/569
+
+*** I want to be able to easily set dev database into a certain state.
+This allows me to always have good starting point for doing my development.
+If I mess up the database state, I can easily go back to this state.
+
+I might want to also share it with others, so they can test / try out stuff.
+In that case I would want to commit it into git.
+
+I might want to have multiple such states, each of them named.
+
+I imagine this would actually be easier to do by doing snapshots of db then having seeding script, so I don't think this is what Prisma is targeting with their =seed=.
+So I probably went here a bit into a different use case -> still interesting though.
+Actuall, I read a bit more and seeding script is also valid way to do this.
+
+As for snapshots -> I guess this would come down to doing postgre exports and then saving those in git and loading them.
+I saw this being called "SQL backup file" somewhere.
+
+*** I want to populate my db with the data that is required for my app to start.
+Examples:
+ - default language
+ - default currency
+ - predefined user roles
+
+I would expect that I can tell wasp which "script" / piece of logic to run to do the seeding.
+
+I might want to have multiple different seeds.
+
+I might want to reuse code from my Wasp project inside of seeding logic. Possibly even actions.
+Actually directly calling actions might be tricky, since they often require authenticated user and
+similar. But, what me as a dev could easily do is extract core logic from the action into a
+standalone function and then call that function both to do action and to do db seeding. So if we
+can reuse Wasp project code, we are already in good place. The question is how do we do that?
+
+** How Prisma does it
+For seeding, prisma just letโs you specify a .ts or .js file that imports Prisma client and does something.
+
+You need to specify it in =package.json= as =prisma.seed= and then it gets called on ~prisma db seed~ or
+on ~prisma migrate reset~ or even ~prisma migrate dev~ if it results in a reset.
+
+You can also alternatively specify .sh script, and then it will run that.
+
+** Evaluation of solutions
+There are two main solutions:
+ 1. Database snapshots
+ 2. Seeding script
+
+*** Database snapshots
+Database snapshots sound relatively straight forward to implement, also easy for devs to create.
+So they could use the app itself, or =db studio=, or some richer postgresql client, or their own scripts,
+to put db into a certain state, and then they can do snapshot and there you go.
+What is not great is that data can easily become out of sync with how app currently works,
+and you need to recreate them as app changes in order to keep them relevant.
+
+*** Seeding scripts
+Seeding script seems to be what most existing solutions do (Laravel, Prisma).
+Good thing about them is that they are code, so they don't have an issue of db snapshots
+that you need to completely recreate them when app changes. Instead, you just modify the code,
+you also have type-checking on your hands, ... .
+What is trickier is that you have to sometimes work around stuff like authentication,
+mocking something, ... -> the part with users and authentication sounds tricky.
+
+*** Conclusion
+I think seeding scripts are way to go.
+Database snasphots are very tempting due to ease of creation and implementation,
+but the fact that you don't have a good way to know when and how you need to recreate them is a big no-no.
+Also, others mostly do seeding scripts -> I am guessing for this same reason.
+
+
+** Seeding script implementation plan
+
+*** What do we want
+ - Be able to write a single JS/TS file that has access to DB and Prisma SDK and can be specified to Wasp as a seed script. Ideally named.
+ - More advanced: that file would be able to reuse JS/TS logic from our Wasp project (server).
+ - Also: it can potentially be just a .sh script. I guess we pass it database url via env var?
+
+*** Brainstorming
+What we really want to do is run a JS/TS file from the @server project.
+When we run server, we do =node server.js= or smth like that (more complex but that is the idea).
+Now for this, we want to do =node seedFile.js=.
+What is great is that this is all the same project, so this =seedFile.js= should be able to access all the stuff from the server, including actions and everything.
+I am guessing prisma does this same thing when you specify =seed= in the package.json file.
+I am not yet sure if we should use their =seed= field in package.json, or do our own custom script in package.json where we call =node= and stuff.
+
+So we are running this seed script in the context of the server, that is important.
+That sounds good / useful.
+
+Can it also be bad / limiting in some way? Is there a way to run it in more standalone context?
+Sure there is -> we could run it in the context of our =db/= npm project. It would have access to Prisma SDK, but not to files from @server.
+If it needed to use stuff from @server project, it could in theory import it, if we made that easy.
+This could also scale to having multiple servers in the future -> it could those that it needs and use them.
+This also makes most sense semantically -> to have db seeding script tied to db it seeds, not to a server.
+
+So if we have access to server code from seed script (be it because it is part of server project or it imports @server package),
+how does that open up using actions?
+We are not (and don't want to be) running the server, so REST API of actions is not available.
+We can call the action functions directly, for sure, but then we need to provide them with context and stuff, inject correct things into them.
+We could have some machinery that enables calling them from seed script, but it would need to provide all the stuff to actions that they normally expect.
+Good thing is we know what they expect, so we can do it.
+Interesting part is also user creation in seeding script, and passing that user to actions -> this will be interesting to explore,
+how we can best support that.
+
+*** Final implementation plan!
+**** MVP
+ - In Wasp:
+ #+BEGIN_SRC
+ app {
+ ...
+ db: {
+ seed:
+ }
+ }
+ #+END_SRC
+
+ - When generating code, we add =seed= entry to =package.json= that points to our seed file =seed.js=
+ that imports the seed function user specified via ServerImport and calls it.
+ This should be all Prisma needs to pick this file up and use it.
+ - Alternatively, we might want to do something more manual, like creating our own script in =package.json=
+ that does something like ~node seed.js~, but let's first try doing it the Prisma way, this is
+ backup plan if we are not happy with how Prisma does it.
+ - Make sure that in seed script we can access code from the @server project (we should be able to),
+ but also make sure that we can directly access Prisma SDK if we want to.
+ - Write a seed script that creates some users and then some resources for those users.
+ Explore how this feels, especially the part with creating and sending user around, if we can support that somehow to be nicer.
+ - Explore how providing .sh as a seed script works, since Prisma supports that.
+ - Write docs about seeding + put an example there of a simple seed script so they can get started with it.
+ This is a good candidate for scaffolding -> create an issue for that.
+ - Explore if this can also be used for production db, to kick start it, and if that even makes sense.
+
+**** Advanced steps
+ - We could support multiple seeding scripts, each one would have a name:
+ #+BEGIN_SRC
+ app {
+ ...
+ db: {
+ seeds: [
+ (, )
+ (, )
+ ]
+ }
+ }
+ #+END_SRC
+ - Explore a bit if there can be done something more to make using Actions easier, in seed script.
+ - Explore the idea of coupling db script with the @db, not the @server, since it makes more sense semantically and scales with the future development of Wasp (multiple servers).
+ Probably create an issue on GH for this.
+
+
diff --git a/waspc/e2e-test/GoldenTest.hs b/waspc/e2e-test/GoldenTest.hs
index 2a8ccd603..e7e3e3df0 100644
--- a/waspc/e2e-test/GoldenTest.hs
+++ b/waspc/e2e-test/GoldenTest.hs
@@ -89,7 +89,14 @@ runGoldenTest goldenTest = do
isTestOutputFileTestable :: FilePath -> Bool
isTestOutputFileTestable fp =
- takeFileName fp `notElem` [".waspinfo", "node_modules", "dev.db", "dev.db-journal", "package-lock.json", ".gitignore"]
+ takeFileName fp
+ `notElem` [ ".waspinfo",
+ "node_modules",
+ "dev.db",
+ "dev.db-journal",
+ "package-lock.json",
+ ".gitignore"
+ ]
writeFileManifest :: String -> [FilePath] -> FilePath -> IO ()
writeFileManifest baseAbsFp filePaths manifestAbsFp = do
diff --git a/waspc/e2e-test/Tests/WaspComplexTest.hs b/waspc/e2e-test/Tests/WaspComplexTest.hs
index 285211bc1..198982549 100644
--- a/waspc/e2e-test/Tests/WaspComplexTest.hs
+++ b/waspc/e2e-test/Tests/WaspComplexTest.hs
@@ -13,6 +13,7 @@ import ShellCommands
waspCliNew,
)
import Util ((<++>))
+import Wasp.Project.Db (databaseUrlEnvVarName)
waspComplexTest :: GoldenTest
waspComplexTest = do
@@ -23,12 +24,14 @@ waspComplexTest = do
]
<++> addServerEnvFile
<++> addDependencies
+ <++> addEmailSender
<++> addClientSetup
<++> addServerSetup
<++> addGoogleAuth
<++> addJob
<++> addAction
<++> addQuery
+ <++> addApi
<++> sequence
[ waspCliCompile
]
@@ -119,7 +122,16 @@ addServerEnvFile = do
envFileContents =
unlines
[ "GOOGLE_CLIENT_ID=google_client_id",
- "GOOGLE_CLIENT_SECRET=google_client_secret"
+ "GOOGLE_CLIENT_SECRET=google_client_secret",
+ -- NOTE: Since we are using PSQL in this test, if we don't set custom
+ -- database url in server/.env, Wasp will set its own, for managed dev db.
+ -- That is problematic because Wasp's db url depends on project's abs path,
+ -- which is not something we have constant during e2e tests, it depends
+ -- on the location where the tests are being run.
+ -- Therefore, we make sure to set custom database url here, to avoid .env
+ -- changing between different machines / setups.
+ databaseUrlEnvVarName <> "=" <> "mock-database-url",
+ "SENDGRID_API_KEY=sendgrid_api_key"
]
addGoogleAuth :: ShellCommandBuilder [ShellCommand]
@@ -223,6 +235,56 @@ addDependencies = do
" ],"
]
+addApi :: ShellCommandBuilder [ShellCommand]
+addApi = do
+ sequence
+ [ appendToWaspFile apiDecls,
+ createFile apiFile "./src/server" "apis.ts"
+ ]
+ where
+ apiDecls =
+ unlines
+ [ "api fooBar {",
+ " fn: import { fooBar } from \"@server/apis.js\",",
+ " httpRoute: (GET, \"/foo/bar\")",
+ " // implicit auth:true",
+ "}",
+ "api fooBaz {",
+ " fn: import { fooBaz } from \"@server/apis.js\",",
+ " httpRoute: (GET, \"/foo/baz\"),",
+ " auth: false",
+ "}"
+ ]
+
+ apiFile =
+ unlines
+ [ "import { FooBar, FooBaz } from '@wasp/apis/types'",
+ "export const fooBar: FooBar = (req, res, context) => {",
+ " res.set('Access-Control-Allow-Origin', '*')",
+ " res.json({ msg: 'Hello, context.user.username!' })",
+ "}",
+ "export const fooBaz: FooBaz = (req, res, context) => {",
+ " res.json({ msg: 'Hello, stranger!' })",
+ "}"
+ ]
+
+addEmailSender :: ShellCommandBuilder [ShellCommand]
+addEmailSender = do
+ sequence
+ [ insertCodeIntoWaspFileAfterVersion emailSender
+ ]
+ where
+ emailSender =
+ unlines
+ [ " emailSender: {",
+ " provider: SendGrid,",
+ " defaultFrom: {",
+ " name: \"Hello\",",
+ " email: \"hello@itsme.com\"",
+ " },",
+ " },"
+ ]
+
insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand
insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion
where
diff --git a/waspc/e2e-test/Tests/WaspJobTest.hs b/waspc/e2e-test/Tests/WaspJobTest.hs
index b0c720cb7..66b5801ed 100644
--- a/waspc/e2e-test/Tests/WaspJobTest.hs
+++ b/waspc/e2e-test/Tests/WaspJobTest.hs
@@ -2,13 +2,17 @@ module Tests.WaspJobTest (waspJob) where
import GoldenTest (GoldenTest, makeGoldenTest)
import ShellCommands
- ( appendToWaspFile,
+ ( ShellCommand,
+ ShellCommandBuilder,
+ appendToWaspFile,
cdIntoCurrentProject,
createFile,
setDbToPSQL,
waspCliCompile,
waspCliNew,
)
+import Util ((<++>))
+import Wasp.Project.Db (databaseUrlEnvVarName)
waspJob :: GoldenTest
waspJob = do
@@ -31,6 +35,25 @@ waspJob = do
cdIntoCurrentProject,
setDbToPSQL,
appendToWaspFile jobDecl,
- createFile jobFile "./src/server/jobs" "bar.js",
- waspCliCompile
+ createFile jobFile "./src/server/jobs" "bar.js"
]
+ <++> addServerEnvFile
+ <++> sequence
+ [ waspCliCompile
+ ]
+
+addServerEnvFile :: ShellCommandBuilder [ShellCommand]
+addServerEnvFile = do
+ sequence [createFile envFileContents "./" ".env.server"]
+ where
+ envFileContents =
+ unlines
+ [ -- NOTE: Since we are using PSQL in this test, if we don't set custom
+ -- database url in server/.env, Wasp will set its own, for managed dev db.
+ -- That is problematic because Wasp's db url depends on project's abs path,
+ -- which is not something we have constant during e2e tests, it depends
+ -- on the location where the tests are being run.
+ -- Therefore, we make sure to set custom database url here, to avoid .env
+ -- changing between different machines / setups.
+ databaseUrlEnvVarName <> "=" <> "mock-database-url"
+ ]
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest
index ac4572ec7..033fe7f5f 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest
@@ -16,7 +16,8 @@ waspBuild/.wasp/build/server/src/app.js
waspBuild/.wasp/build/server/src/config.js
waspBuild/.wasp/build/server/src/core/AuthError.js
waspBuild/.wasp/build/server/src/core/HttpError.js
-waspBuild/.wasp/build/server/src/dbClient.js
+waspBuild/.wasp/build/server/src/dbClient.ts
+waspBuild/.wasp/build/server/src/dbSeed/types.ts
waspBuild/.wasp/build/server/src/entities/index.ts
waspBuild/.wasp/build/server/src/jobs/core/Job.js
waspBuild/.wasp/build/server/src/jobs/core/SubmittedJob.js
@@ -29,6 +30,7 @@ waspBuild/.wasp/build/server/src/routes/index.js
waspBuild/.wasp/build/server/src/routes/operations/index.js
waspBuild/.wasp/build/server/src/server.ts
waspBuild/.wasp/build/server/src/types/index.ts
+waspBuild/.wasp/build/server/src/universal/types.ts
waspBuild/.wasp/build/server/src/universal/url.ts
waspBuild/.wasp/build/server/src/utils.js
waspBuild/.wasp/build/server/tsconfig.json
@@ -44,7 +46,7 @@ waspBuild/.wasp/build/web-app/scripts/validate-env.mjs
waspBuild/.wasp/build/web-app/src/actions/core.d.ts
waspBuild/.wasp/build/web-app/src/actions/core.js
waspBuild/.wasp/build/web-app/src/actions/index.ts
-waspBuild/.wasp/build/web-app/src/api.js
+waspBuild/.wasp/build/web-app/src/api.ts
waspBuild/.wasp/build/web-app/src/config.js
waspBuild/.wasp/build/web-app/src/entities/index.ts
waspBuild/.wasp/build/web-app/src/ext-src/Main.css
@@ -53,7 +55,7 @@ waspBuild/.wasp/build/web-app/src/ext-src/vite-env.d.ts
waspBuild/.wasp/build/web-app/src/ext-src/waspLogo.png
waspBuild/.wasp/build/web-app/src/index.tsx
waspBuild/.wasp/build/web-app/src/logo.png
-waspBuild/.wasp/build/web-app/src/operations/index.js
+waspBuild/.wasp/build/web-app/src/operations/index.ts
waspBuild/.wasp/build/web-app/src/operations/resources.js
waspBuild/.wasp/build/web-app/src/operations/updateHandlersMap.js
waspBuild/.wasp/build/web-app/src/queries/core.d.ts
@@ -62,6 +64,12 @@ waspBuild/.wasp/build/web-app/src/queries/index.d.ts
waspBuild/.wasp/build/web-app/src/queries/index.js
waspBuild/.wasp/build/web-app/src/queryClient.js
waspBuild/.wasp/build/web-app/src/router.jsx
+waspBuild/.wasp/build/web-app/src/storage.ts
+waspBuild/.wasp/build/web-app/src/test/index.ts
+waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx
+waspBuild/.wasp/build/web-app/src/test/vitest/setup.ts
+waspBuild/.wasp/build/web-app/src/types.ts
+waspBuild/.wasp/build/web-app/src/universal/types.ts
waspBuild/.wasp/build/web-app/src/universal/url.ts
waspBuild/.wasp/build/web-app/src/utils.js
waspBuild/.wasp/build/web-app/src/vite-env.d.ts
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums
index c85ba4be7..5da30f0eb 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums
@@ -53,7 +53,7 @@
"file",
"server/package.json"
],
- "4d2df7cd7e0c73cd693c8e6c1c5fa80ab09ff98aeb99989933cdbe3ce5fa78f2"
+ "3a9ca10f3fa092bb0b3c1dafe4a5bf5449cb0d45d3cf7612d363191b5055d3e0"
],
[
[
@@ -74,7 +74,7 @@
"file",
"server/src/_types/index.ts"
],
- "cbfe28e0bdf628d038f3435249f2d41c8bd8c8c5358f4c2d8fe631965f85a9f4"
+ "ab483e1b9552d943a44baa1bbfc2d0b31f231a0fc5c945e12e9b3aa6980b6f8d"
],
[
[
@@ -95,14 +95,14 @@
"file",
"server/src/app.js"
],
- "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72"
+ "f7df4b76a53a92117e0ddca41edd47961cf20ee6f13cc4d252e11c2a293a6e76"
],
[
[
"file",
"server/src/config.js"
],
- "60a63ed453f6a6d8306f7a3660eff80b5f9803b37e5865db66fcae80df918a68"
+ "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[
@@ -121,10 +121,17 @@
[
[
"file",
- "server/src/dbClient.js"
+ "server/src/dbClient.ts"
],
"20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43"
],
+ [
+ [
+ "file",
+ "server/src/dbSeed/types.ts"
+ ],
+ "47442c546d286164c2cdb3359deb45b47f115df2ca72bb181b4639bee2059d38"
+ ],
[
[
"file",
@@ -207,7 +214,14 @@
"file",
"server/src/types/index.ts"
],
- "37352cdaeae444c90632fe788d1e49eca3927ef8755702ba95c81c8b90d9f408"
+ "1fd50e251e340a5bc8c51369766e8c889cf892cdbe6593b4d58a6ee585b6d2cc"
+ ],
+ [
+ [
+ "file",
+ "server/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
],
[
[
@@ -221,14 +235,14 @@
"file",
"server/src/utils.js"
],
- "68a5794f55e24b303d81456a1181a3a2cd70773f6ebc4e7a63dac064834aa8e9"
+ "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[
"file",
"server/tsconfig.json"
],
- "c58318dce36bc48fdd1e998af906b5026c87b1d80415653b03a7285acdb56d69"
+ "f2632965c1e3678fcc0e63b83d7e33fea1a9008ef5fd5a2f5e7bf278337c3e02"
],
[
[
@@ -270,7 +284,7 @@
"file",
"web-app/package.json"
],
- "baa475d963b525435bc9fe392679d8750815f6a4477d221250440c29086bd6cc"
+ "ec2ca08cd231bfbb242773ae9d609c3919bad035fd4e01da71bcac0d6f18185a"
],
[
[
@@ -305,28 +319,28 @@
"file",
"web-app/src/actions/core.d.ts"
],
- "d649b4823ede60119858b868dac9b122da4a3185391e6b9242ff7f6dc5c25d31"
+ "34b44699906b98ca28050479b66254e64073a403b571a8fd1a227395d27becae"
],
[
[
"file",
"web-app/src/actions/core.js"
],
- "5c4dcdec74fb014a8edbb3d240bcbbfc829e201bce64132598b444db14a2bd45"
+ "f38003d51d9754952bf595a25fdb44580d88af3bcd6658848cf8a339a8240689"
],
[
[
"file",
"web-app/src/actions/index.ts"
],
- "0f294c2f1d50a1473f6d332ef17944e7475c7d83a2180f6e2c9c9aecf25439f4"
+ "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092"
],
[
[
"file",
- "web-app/src/api.js"
+ "web-app/src/api.ts"
],
- "bcdb1fdc97fd622fbc882e4a16a7137e849748d6776b960121df6ccab577a3a9"
+ "ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
],
[
[
@@ -387,16 +401,16 @@
[
[
"file",
- "web-app/src/operations/index.js"
+ "web-app/src/operations/index.ts"
],
- "6ab717db2304b6134073aa71144b213b86f8d68a106549da06e193d18683dd87"
+ "7ec3870a9313ec17d2282f9002da4a5d4da3c096c5fb0927acd008f1bec60bb3"
],
[
[
"file",
"web-app/src/operations/resources.js"
],
- "8bb71b7d7ef3962c28b9db1554500452e7f91b0c9fd898d5cafcdb7229f6db1e"
+ "c08445d84f9931ebc3e4f975017bf88c03b6bee9aa588e945d52fe3aee9c4d70"
],
[
[
@@ -410,21 +424,21 @@
"file",
"web-app/src/queries/core.d.ts"
],
- "f0b289140e92738451be386ca73a2fd1c84e9951eb2f1b9c6c09dfa3079d0c74"
+ "8ac475ed46a373ddc47e37dd557315c9c15abcde527749bb40c08527130e0567"
],
[
[
"file",
"web-app/src/queries/core.js"
],
- "2daf5b414722204281d65e954ce862a6fc586e8907b202800694909d23957c5e"
+ "3ac4a8402a7d6af3eb4a90ec59bcfdd32585f3f2dc6d6bed8a1140cea8fdb7f2"
],
[
[
"file",
"web-app/src/queries/index.d.ts"
],
- "efc70de9916a60e19e0c86aaf955b0be0c999ba5c30139c3b6b98bcc4d382091"
+ "f3a7cf037867cd7f8ce76cbca5e5eb2aacb9ca69933c182bce2f7ff935c4d385"
],
[
[
@@ -447,6 +461,48 @@
],
"103724de16eb74300d9fab366bf73401ffd6bf9018394044c1d4ade22731868e"
],
+ [
+ [
+ "file",
+ "web-app/src/storage.ts"
+ ],
+ "1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/index.ts"
+ ],
+ "cb2e2dc33df8afc0d4453f4322a4e2af92f3345e9622e0416fa87e34d6acb9d8"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/helpers.tsx"
+ ],
+ "6e1def1b1755c23d766a1581200295429238beaaea67ba066ba81e3b7768f8bb"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/setup.ts"
+ ],
+ "1c08b10e428cec3939e0ab269c9a02694e196de7c5dd9f18372424bdccbc5028"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/types.ts"
+ ],
+ "282f6bdef1e7e8846ad53909bbf88c54cd04a0594b77b1e87cb69266de84cc6d"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
+ ],
[
[
"file",
@@ -473,7 +529,7 @@
"file",
"web-app/tsconfig.json"
],
- "27e39dd3e6155ffccdb1d9cb0cba8db7d9e06e10958bee900340a2d9f17400c8"
+ "887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
],
[
[
@@ -487,6 +543,6 @@
"file",
"web-app/vite.config.ts"
],
- "ab5c138ab0ab01f5108c2da9a8bf6b673b79115ca97276f6293da8e3bc0a45b1"
+ "0ab8b3892a5d5d25b85646ef30e8b2487904415021912e68670fab316b2ecf2d"
]
]
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json
index 21bf1e41b..d51f20798 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json
@@ -1 +1 @@
-{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}}
\ No newline at end of file
+{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json
index 3ec8f33b6..a6fa59748 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json
@@ -1,6 +1,6 @@
{
"dependencies": {
- "@prisma/client": "4.5.0",
+ "@prisma/client": "4.12.0",
"cookie-parser": "~1.4.6",
"cors": "^2.8.5",
"dotenv": "16.0.2",
@@ -10,15 +10,18 @@
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
+ "rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
+ "superjson": "^1.12.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/express": "^4.17.13",
+ "@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"nodemon": "^2.0.19",
- "prisma": "4.5.0",
+ "prisma": "4.12.0",
"standard": "^17.0.0",
"typescript": "^4.8.4"
},
@@ -35,12 +38,13 @@
"sodium-native": "3.3.0"
}
},
+ "prisma": {},
"private": true,
"scripts": {
"build": "npx tsc",
"build-and-start": "npm run build && npm run start",
- "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
+ "db-seed": "npm run build && NODE_PATH=dist node -r dotenv/config dist/dbSeed.js",
"postinstall": "patch-package",
"standard": "standard",
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js",
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/_types/index.ts
index 27ec89ccc..95efc986b 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/_types/index.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/_types/index.ts
@@ -1,3 +1,6 @@
+import { type Expand } from "../universal/types.js";
+import { type Request, type Response } from 'express'
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import prisma from "../dbClient.js"
import { type _Entity } from "./taggedEntities"
@@ -9,12 +12,21 @@ export type Action = Operation = (
args: Input,
- context: Expand>,
-) => Promise
+ context: Context,
+) => Output | Promise
-type OperationContext = {
- entities: Expand>
-}
+export type Api<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: Context,
+) => void
type EntityMap = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
@@ -23,9 +35,7 @@ type EntityMap = {
type PrismaDelegate = {
}
-// This is a helper type used exclusively for DX purposes. It's a No-op for the
-// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
-// type constructors) to make it more readable for the user.
-//
-// Check this SO answer for details: https://stackoverflow.com/a/57683652
-type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never
+type Context = Expand<{
+ entities: Expand>
+}>
+
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js
index 7ffcd317d..7d87c21e4 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js
@@ -16,7 +16,7 @@ const app = express()
app.use(helmet())
app.use(cors({
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
- origin: config.frontendUrl,
+ origin: config.allowedCORSOrigins,
}))
app.use(logger('dev'))
app.use(express.json())
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js
index c23cf0959..9230b7dc5 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js
@@ -12,17 +12,31 @@ const env = process.env.NODE_ENV || 'development'
const config = {
all: {
env,
+ isDevelopment: env === 'development',
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
frontendUrl: undefined,
+ allowedCORSOrigins: [],
},
- development: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000',
- },
- production: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL),
- }
+ development: getDevelopmentConfig(),
+ production: getProductionConfig(),
}
const resolvedConfig = merge(config.all, config[env])
export default resolvedConfig
+
+function getDevelopmentConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000';
+ return {
+ frontendUrl,
+ allowedCORSOrigins: '*',
+ }
+}
+
+function getProductionConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL);
+ return {
+ frontendUrl,
+ allowedCORSOrigins: [frontendUrl],
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts
similarity index 100%
rename from waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.js
rename to waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbSeed/types.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbSeed/types.ts
new file mode 100644
index 000000000..fe0d5396a
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbSeed/types.ts
@@ -0,0 +1,3 @@
+import type { PrismaClient } from '@prisma/client'
+
+export type DbSeedFn = (prismaClient: PrismaClient) => Promise
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts
index 994904589..bef664aac 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts
@@ -1,4 +1,5 @@
-import { Application } from 'express'
+
+import { type Application } from 'express'
import { Server } from 'http'
export type ServerSetupFn = (context: ServerSetupFnContext) => Promise
@@ -8,5 +9,7 @@ export type ServerSetupFnContext = {
server: Server,
}
-export { Application } from 'express'
+export type { Application } from 'express'
export { Server } from 'http'
+
+
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/universal/types.ts
new file mode 100644
index 000000000..8cadbd740
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/universal/types.ts
@@ -0,0 +1,31 @@
+// This is a helper type used exclusively for DX purposes. It's a No-op for the
+// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
+// type constructors) to make it more readable for the user.
+//
+// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
+export type Expand = T extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : T extends infer O
+ ? { [K in keyof O]: O[K] }
+ : never
+
+// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
+// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
+// details). Until it's fixed, we're using our own type for this.
+//
+// TODO: investigate further. This most likely has something to do with an
+// unsatisfied 'extends' constraints. A mismatch is probably happening with
+// function parameter types and/or return types (check '_ReturnType' below for
+// more).
+export type _Awaited = T extends Promise
+ ? _Awaited
+ : T
+
+// TypeScript's native ReturnType does not work for functions of type '(...args:
+// never[]) => unknown' (and that's what operations currently use).
+//
+// TODO: investigate how to properly specify the 'extends' constraint for function
+// type (i.e., any vs never and unknown) and stick with that. Take DX into
+// consideration.
+export type _ReturnType unknown> =
+ T extends (...args: never[]) => infer R ? R : never
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js
index e07b121be..d6c7ed639 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js
@@ -1,6 +1,10 @@
import Prisma from '@prisma/client'
import HttpError from './core/HttpError.js'
+import { readdir } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from 'url';
+
/**
* Decorator for async express middleware that handles promise rejections.
* @param {Func} middleware - Express middleware function.
@@ -28,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
- message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
+ message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {
@@ -45,3 +49,31 @@ export const prismaErrorToHttpError = (e) => {
}
export const sleep = ms => new Promise(r => setTimeout(r, ms))
+
+export function getDirFromFileUrl(fileUrl) {
+ return fileURLToPath(dirname(fileUrl));
+}
+
+export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) {
+ const pathToDir = join(absoluteDir, relativePath);
+
+ return new Promise((resolve, reject) => {
+ readdir(pathToDir, async (err, files) => {
+ if (err) {
+ return reject(err);
+ }
+ const importPromises = files
+ .filter((file) => file.endsWith(".js") && isWhitelisted(file))
+ .map((file) => import(`${pathToDir}/${file}`));
+ resolve(Promise.all(importPromises));
+ });
+ });
+
+ function isWhitelisted(file) {
+ // No whitelist means all files are whitelisted
+ if (!Array.isArray(whitelist)) {
+ return true;
+ }
+ return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile));
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/tsconfig.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/tsconfig.json
index 562d7839c..8999362fe 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/tsconfig.json
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/tsconfig.json
@@ -5,6 +5,7 @@
"strict": false,
// Overriding this because we want to use top-level await
"module": "esnext",
+ "target": "es2017",
// Enable source map for debugging
"sourceMap": true,
// The remaining settings should match node18/tsconfig.json, but I kept
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json
index 998b21997..2e4c4b287 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json
@@ -12,22 +12,29 @@
]
},
"dependencies": {
- "@prisma/client": "4.5.0",
+ "@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.13.0",
"axios": "^0.27.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
- "react-router-dom": "^5.3.3"
+ "react-router-dom": "^5.3.3",
+ "superjson": "^1.12.2"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^5.16.5",
+ "@testing-library/react": "^12.1.5",
"@tsconfig/vite-react": "^1.0.1",
"@types/react": "^17.0.53",
"@types/react-dom": "^17.0.19",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.0.0",
+ "@vitest/ui": "^0.29.3",
"dotenv": "^16.0.3",
+ "jsdom": "^21.1.1",
+ "msw": "^1.1.0",
"typescript": "^4.9.3",
- "vite": "^4.1.0"
+ "vite": "^4.1.0",
+ "vitest": "^0.29.3"
},
"engineStrict": true,
"engines": {
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.d.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.d.ts
index 8a38c25e3..fa31f329f 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.d.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.d.ts
@@ -1,3 +1,13 @@
-import { Action } from '.'
+import { type Action } from '.'
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createAction (actionRoute: string, entitiesUsed: unknown[]): Action
+export function createAction(
+ actionRoute: string,
+ entitiesUsed: unknown[]
+): ActionFor
+
+type ActionFor = Expand<
+ Action[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendAction = (args: never, context: any) => unknown
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.js
index 3258f0d85..440e90614 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
-export function createAction(actionRoute, entitiesUsed) {
+export function createAction(relativeActionRoute, entitiesUsed) {
+ const actionRoute = makeOperationRoute(relativeActionRoute)
+
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts
index 292f8849b..5e4dfedd1 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts
@@ -5,9 +5,12 @@ import {
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
-import { Query } from '../queries';
+import { type Query } from '../queries';
-export type Action = (args?: Input) => Promise;
+export type Action =
+ [Input] extends [never] ?
+ (args?: unknown) => Promise :
+ (args: Input) => Promise
/**
* An options object passed into the `useAction` hook and used to enhance the
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts
similarity index 58%
rename from waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.js
rename to waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts
index 98238ee21..09d848be6 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts
@@ -1,44 +1,39 @@
-import axios from 'axios'
+import axios, { type AxiosError } from 'axios'
import config from './config'
+import { storage } from './storage'
const api = axios.create({
baseURL: config.apiUrl,
})
-const WASP_APP_AUTH_TOKEN_NAME = "authToken"
+const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
-let authToken = null
-if (window.localStorage) {
- authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
-}
+let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
-export const setAuthToken = (token) => {
- if (typeof token !== 'string') {
- throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
- }
+export function setAuthToken (token: string): void {
authToken = token
- window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
+ storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
}
-export const clearAuthToken = () => {
+export function clearAuthToken(): void {
authToken = undefined
- window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
+ storage.remove(WASP_APP_AUTH_TOKEN_NAME)
}
-export const clearLocalStorage = () => {
+export function removeLocalUserData(): void {
authToken = undefined
- window.localStorage && window.localStorage.clear()
+ storage.clear()
}
-api.interceptors.request.use(request => {
+api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
}
return request
})
-api.interceptors.response.use(undefined, error => {
+api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
}
@@ -50,7 +45,7 @@ api.interceptors.response.use(undefined, error => {
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
-export const handleApiError = (error) => {
+export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
@@ -61,14 +56,23 @@ export const handleApiError = (error) => {
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
- const e = new Error(responseJson?.message || error.message)
- e.statusCode = responseStatusCode
- e.data = responseJson?.data
- throw e
+ throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
+class WaspHttpError extends Error {
+ statusCode: number
+
+ data: unknown
+
+ constructor (statusCode: number, message: string, data: unknown) {
+ super(message)
+ this.statusCode = statusCode
+ this.data = data
+ }
+}
+
export default api
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.js
deleted file mode 100644
index e84d0373a..000000000
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import api, { handleApiError } from '../api.js'
-
-export async function callOperation(operationRoute, args) {
- try {
- const response = await api.post(`/${operationRoute}`, args)
- return response.data
- } catch (error) {
- handleApiError(error)
- }
-}
-
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.ts
new file mode 100644
index 000000000..2d4494b05
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/index.ts
@@ -0,0 +1,22 @@
+import api, { handleApiError } from '../api'
+import { HttpMethod } from '../types'
+import {
+ serialize as superjsonSerialize,
+ deserialize as superjsonDeserialize,
+ } from 'superjson'
+
+export type OperationRoute = { method: HttpMethod, path: string }
+
+export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
+ try {
+ const superjsonArgs = superjsonSerialize(args)
+ const response = await api.post(operationRoute.path, superjsonArgs)
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
+
+export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
+ return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js
index 70c0458a4..779cd55a5 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js
@@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
/**
* Remembers that specified query is using specified resources.
* If called multiple times for same query, resources are added, not reset.
- * @param {string} queryCacheKey - Unique key under used to identify query in the cache.
+ * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
* @param {string[]} resources - Names of resources that query is using.
*/
export function addResourcesUsedByQuery(queryCacheKey, resources) {
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.d.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.d.ts
index edde495db..e1bdbe478 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.d.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.d.ts
@@ -1,3 +1,23 @@
-export type Query = (args: Input) => Promise
+import { type Query } from '.'
+import { Route } from '../types';
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createQuery (queryRoute: string, entitiesUsed: any[]): Query
+export function createQuery(
+ queryRoute: string,
+ entitiesUsed: any[]
+): QueryFor
+
+export function addMetadataToQuery(
+ query: (...args: any[]) => Promise,
+ metadata: {
+ relativeQueryPath: string;
+ queryRoute: Route;
+ entitiesUsed: string[];
+ },
+): void
+
+type QueryFor = Expand<
+ Query[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendQuery = (args: never, context: any) => unknown
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.js
index bfe2d90c6..5103db1d8 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.js
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
addResourcesUsedByQuery,
getActiveOptimisticUpdates,
} from '../operations/resources'
-export function createQuery(queryRoute, entitiesUsed) {
+export function createQuery(relativeQueryPath, entitiesUsed) {
+ const queryRoute = makeOperationRoute(relativeQueryPath)
+
async function query(queryKey, queryArgs) {
const serverResult = await callOperation(queryRoute, queryArgs)
return getActiveOptimisticUpdates(queryKey).reduce(
@@ -13,8 +15,13 @@ export function createQuery(queryRoute, entitiesUsed) {
)
}
- query.queryCacheKey = [queryRoute]
- addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+ addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
return query
}
+
+export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
+ query.queryCacheKey = [relativeQueryPath]
+ query.route = queryRoute
+ addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/index.d.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/index.d.ts
index 3716204db..c007ff4c9 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/index.d.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/queries/index.d.ts
@@ -1,8 +1,10 @@
import { UseQueryResult } from "@tanstack/react-query";
-export type Query = (args: Input) => Promise
+export type Query = {
+ (queryCacheKey: string[], args: Input): Promise
+}
-export function useQuery (
+export function useQuery (
queryFn: Query ,
queryFnArgs?: Input, options?: any
): UseQueryResult
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/storage.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/storage.ts
new file mode 100644
index 000000000..91e039903
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/storage.ts
@@ -0,0 +1,44 @@
+export type DataStore = {
+ set(key: string, value: unknown): void;
+ get(key: string): unknown;
+ remove(key: string): void;
+ clear(): void;
+};
+
+function createLocalStorageDataStore(prefix: string): DataStore {
+ return {
+ set(key, value) {
+ ensureLocalStorageIsAvailable();
+ localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
+ },
+ get(key) {
+ ensureLocalStorageIsAvailable();
+ const value = localStorage.getItem(`${prefix}:${key}`);
+ try {
+ return value ? JSON.parse(value) : undefined;
+ } catch (e: any) {
+ return undefined;
+ }
+ },
+ remove(key) {
+ ensureLocalStorageIsAvailable();
+ localStorage.removeItem(`${prefix}:${key}`);
+ },
+ clear() {
+ ensureLocalStorageIsAvailable();
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith(prefix)) {
+ localStorage.removeItem(key);
+ }
+ });
+ },
+ };
+}
+
+export const storage = createLocalStorageDataStore('wasp');
+
+function ensureLocalStorageIsAvailable(): void {
+ if (!window.localStorage) {
+ throw new Error('Local storage is not available.');
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/index.ts
new file mode 100644
index 000000000..f002be83c
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/index.ts
@@ -0,0 +1 @@
+export { renderInContext, mockServer } from './vitest/helpers'
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx
new file mode 100644
index 000000000..655c01a29
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/helpers.tsx
@@ -0,0 +1,93 @@
+import { ReactElement } from 'react'
+import { serialize as superjsonSerialize } from 'superjson'
+import { rest, type ResponseResolver, type RestContext } from 'msw'
+import { setupServer, type SetupServer } from 'msw/node'
+import { BrowserRouter as Router } from 'react-router-dom'
+import { render, RenderResult, cleanup } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { beforeAll, afterEach, afterAll } from 'vitest'
+import { Query } from '../../queries'
+import config from '../../config'
+import { HttpMethod, Route } from '../../types'
+
+export { Route } from '../../types'
+
+export type MockQuery = (
+ query: Query ,
+ resJson: MockOutput
+) => void
+
+export type MockApi = (route: Route, resJson: unknown) => void
+
+// Inspired by the Tanstack React Query helper:
+// https://github.com/TanStack/query/blob/4ae99561ca3383d6de3f4aad656a49ba4a17b57a/packages/react-query/src/__tests__/utils.tsx#L7-L26
+export function renderInContext(ui: ReactElement): RenderResult {
+ const client = new QueryClient()
+ const { rerender, ...result } = render(
+
+ {ui}
+
+ )
+ return {
+ ...result,
+ rerender: (rerenderUi: ReactElement) =>
+ rerender(
+
+ {rerenderUi}
+
+ ),
+ }
+}
+
+export function mockServer(): {
+ server: SetupServer
+ mockQuery: MockQuery
+ mockApi: MockApi
+} {
+ const server: SetupServer = setupServer()
+
+ beforeAll(() => server.listen())
+ afterEach(() => {
+ server.resetHandlers()
+ cleanup()
+ })
+ afterAll(() => server.close())
+
+ const mockQuery: MockQuery = (query, mockData) => {
+ const route = (query as unknown as { route: Route }).route
+ mockRoute(server, route, (_req, res, ctx) =>
+ res(ctx.json(superjsonSerialize(mockData)))
+ )
+ }
+
+ const mockApi: MockApi = (route, mockData) => {
+ mockRoute(server, route, (_req, res, ctx) => res(ctx.json(mockData)))
+ }
+
+ return { server, mockQuery, mockApi }
+}
+
+function mockRoute(
+ server: SetupServer,
+ route: Route,
+ responseHandler: ResponseResolver
+) {
+ if (!Object.values(HttpMethod).includes(route.method)) {
+ throw new Error(
+ `Unsupported query method for mocking: ${
+ route.method
+ }. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`
+ )
+ }
+
+ const url = `${config.apiUrl}${route.path}`
+
+ const handlers: Record[0]> = {
+ [HttpMethod.Get]: rest.get(url, responseHandler),
+ [HttpMethod.Post]: rest.post(url, responseHandler),
+ [HttpMethod.Put]: rest.put(url, responseHandler),
+ [HttpMethod.Delete]: rest.delete(url, responseHandler),
+ }
+
+ server.use(handlers[route.method])
+}
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/setup.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/setup.ts
new file mode 100644
index 000000000..d263e51f4
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/test/vitest/setup.ts
@@ -0,0 +1,4 @@
+import matchers from '@testing-library/jest-dom/matchers'
+import { expect } from 'vitest'
+
+expect.extend(matchers)
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/types.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/types.ts
new file mode 100644
index 000000000..982b766e3
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/types.ts
@@ -0,0 +1,9 @@
+// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
+export enum HttpMethod {
+ Get = 'GET',
+ Post = 'POST',
+ Put = 'PUT',
+ Delete = 'DELETE',
+}
+
+export type Route = { method: HttpMethod; path: string }
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/universal/types.ts
new file mode 100644
index 000000000..8cadbd740
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/universal/types.ts
@@ -0,0 +1,31 @@
+// This is a helper type used exclusively for DX purposes. It's a No-op for the
+// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
+// type constructors) to make it more readable for the user.
+//
+// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
+export type Expand = T extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : T extends infer O
+ ? { [K in keyof O]: O[K] }
+ : never
+
+// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
+// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
+// details). Until it's fixed, we're using our own type for this.
+//
+// TODO: investigate further. This most likely has something to do with an
+// unsatisfied 'extends' constraints. A mismatch is probably happening with
+// function parameter types and/or return types (check '_ReturnType' below for
+// more).
+export type _Awaited = T extends Promise
+ ? _Awaited
+ : T
+
+// TypeScript's native ReturnType does not work for functions of type '(...args:
+// never[]) => unknown' (and that's what operations currently use).
+//
+// TODO: investigate how to properly specify the 'extends' constraint for function
+// type (i.e., any vs never and unknown) and stick with that. Take DX into
+// consideration.
+export type _ReturnType unknown> =
+ T extends (...args: never[]) => infer R ? R : never
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/tsconfig.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/tsconfig.json
index fdeb6c94b..c5012408c 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/tsconfig.json
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "@tsconfig/vite-react/tsconfig.json",
"compilerOptions": {
// Temporary loosen the type checking until we can address all the errors.
+ "jsx": "preserve",
"allowJs": true,
"strict": false
},
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/vite.config.ts
index 4948c6fe7..8b3650d21 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/vite.config.ts
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
@@ -6,9 +7,15 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
+ host: '0.0.0.0',
+ open: true,
},
envPrefix: 'REACT_APP_',
build: {
outDir: 'build',
},
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/vitest/setup.ts'],
+ },
})
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp
index ba3d2e77d..20b078861 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp
+++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp
@@ -1,7 +1,7 @@
app waspBuild {
db: { system: PostgreSQL },
wasp: {
- version: "^0.9.0"
+ version: "^0.10.0"
},
title: "waspBuild"
}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest
index e1564fea5..3996bca4c 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest
@@ -3,6 +3,7 @@ waspCompile/.wasp/out/.waspchecksums
waspCompile/.wasp/out/Dockerfile
waspCompile/.wasp/out/db/schema.prisma
waspCompile/.wasp/out/installedFullStackNpmDependencies.json
+waspCompile/.wasp/out/server/.env
waspCompile/.wasp/out/server/.npmrc
waspCompile/.wasp/out/server/README.md
waspCompile/.wasp/out/server/nodemon.json
@@ -16,7 +17,8 @@ waspCompile/.wasp/out/server/src/app.js
waspCompile/.wasp/out/server/src/config.js
waspCompile/.wasp/out/server/src/core/AuthError.js
waspCompile/.wasp/out/server/src/core/HttpError.js
-waspCompile/.wasp/out/server/src/dbClient.js
+waspCompile/.wasp/out/server/src/dbClient.ts
+waspCompile/.wasp/out/server/src/dbSeed/types.ts
waspCompile/.wasp/out/server/src/entities/index.ts
waspCompile/.wasp/out/server/src/jobs/core/Job.js
waspCompile/.wasp/out/server/src/jobs/core/SubmittedJob.js
@@ -29,9 +31,11 @@ waspCompile/.wasp/out/server/src/routes/index.js
waspCompile/.wasp/out/server/src/routes/operations/index.js
waspCompile/.wasp/out/server/src/server.ts
waspCompile/.wasp/out/server/src/types/index.ts
+waspCompile/.wasp/out/server/src/universal/types.ts
waspCompile/.wasp/out/server/src/universal/url.ts
waspCompile/.wasp/out/server/src/utils.js
waspCompile/.wasp/out/server/tsconfig.json
+waspCompile/.wasp/out/web-app/.env
waspCompile/.wasp/out/web-app/.npmrc
waspCompile/.wasp/out/web-app/README.md
waspCompile/.wasp/out/web-app/index.html
@@ -44,7 +48,7 @@ waspCompile/.wasp/out/web-app/scripts/validate-env.mjs
waspCompile/.wasp/out/web-app/src/actions/core.d.ts
waspCompile/.wasp/out/web-app/src/actions/core.js
waspCompile/.wasp/out/web-app/src/actions/index.ts
-waspCompile/.wasp/out/web-app/src/api.js
+waspCompile/.wasp/out/web-app/src/api.ts
waspCompile/.wasp/out/web-app/src/config.js
waspCompile/.wasp/out/web-app/src/entities/index.ts
waspCompile/.wasp/out/web-app/src/ext-src/Main.css
@@ -53,7 +57,7 @@ waspCompile/.wasp/out/web-app/src/ext-src/vite-env.d.ts
waspCompile/.wasp/out/web-app/src/ext-src/waspLogo.png
waspCompile/.wasp/out/web-app/src/index.tsx
waspCompile/.wasp/out/web-app/src/logo.png
-waspCompile/.wasp/out/web-app/src/operations/index.js
+waspCompile/.wasp/out/web-app/src/operations/index.ts
waspCompile/.wasp/out/web-app/src/operations/resources.js
waspCompile/.wasp/out/web-app/src/operations/updateHandlersMap.js
waspCompile/.wasp/out/web-app/src/queries/core.d.ts
@@ -62,6 +66,12 @@ waspCompile/.wasp/out/web-app/src/queries/index.d.ts
waspCompile/.wasp/out/web-app/src/queries/index.js
waspCompile/.wasp/out/web-app/src/queryClient.js
waspCompile/.wasp/out/web-app/src/router.jsx
+waspCompile/.wasp/out/web-app/src/storage.ts
+waspCompile/.wasp/out/web-app/src/test/index.ts
+waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx
+waspCompile/.wasp/out/web-app/src/test/vitest/setup.ts
+waspCompile/.wasp/out/web-app/src/types.ts
+waspCompile/.wasp/out/web-app/src/universal/types.ts
waspCompile/.wasp/out/web-app/src/universal/url.ts
waspCompile/.wasp/out/web-app/src/utils.js
waspCompile/.wasp/out/web-app/src/vite-env.d.ts
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums
index 385e00075..a520e762a 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums
@@ -20,6 +20,13 @@
],
"93f3b154b04fce7819e24aeb1691cd1c78f731f41a2f9e0213d54ef783f2bc38"
],
+ [
+ [
+ "file",
+ "server/.env"
+ ],
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ ],
[
[
"file",
@@ -53,7 +60,7 @@
"file",
"server/package.json"
],
- "4d2df7cd7e0c73cd693c8e6c1c5fa80ab09ff98aeb99989933cdbe3ce5fa78f2"
+ "3a9ca10f3fa092bb0b3c1dafe4a5bf5449cb0d45d3cf7612d363191b5055d3e0"
],
[
[
@@ -74,7 +81,7 @@
"file",
"server/src/_types/index.ts"
],
- "cbfe28e0bdf628d038f3435249f2d41c8bd8c8c5358f4c2d8fe631965f85a9f4"
+ "ab483e1b9552d943a44baa1bbfc2d0b31f231a0fc5c945e12e9b3aa6980b6f8d"
],
[
[
@@ -95,14 +102,14 @@
"file",
"server/src/app.js"
],
- "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72"
+ "f7df4b76a53a92117e0ddca41edd47961cf20ee6f13cc4d252e11c2a293a6e76"
],
[
[
"file",
"server/src/config.js"
],
- "60a63ed453f6a6d8306f7a3660eff80b5f9803b37e5865db66fcae80df918a68"
+ "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[
@@ -121,10 +128,17 @@
[
[
"file",
- "server/src/dbClient.js"
+ "server/src/dbClient.ts"
],
"20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43"
],
+ [
+ [
+ "file",
+ "server/src/dbSeed/types.ts"
+ ],
+ "47442c546d286164c2cdb3359deb45b47f115df2ca72bb181b4639bee2059d38"
+ ],
[
[
"file",
@@ -207,7 +221,14 @@
"file",
"server/src/types/index.ts"
],
- "37352cdaeae444c90632fe788d1e49eca3927ef8755702ba95c81c8b90d9f408"
+ "1fd50e251e340a5bc8c51369766e8c889cf892cdbe6593b4d58a6ee585b6d2cc"
+ ],
+ [
+ [
+ "file",
+ "server/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
],
[
[
@@ -221,14 +242,21 @@
"file",
"server/src/utils.js"
],
- "68a5794f55e24b303d81456a1181a3a2cd70773f6ebc4e7a63dac064834aa8e9"
+ "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[
"file",
"server/tsconfig.json"
],
- "c58318dce36bc48fdd1e998af906b5026c87b1d80415653b03a7285acdb56d69"
+ "f2632965c1e3678fcc0e63b83d7e33fea1a9008ef5fd5a2f5e7bf278337c3e02"
+ ],
+ [
+ [
+ "file",
+ "web-app/.env"
+ ],
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
],
[
[
@@ -270,7 +298,7 @@
"file",
"web-app/package.json"
],
- "82391614a4b461fddbfe2fb9945e612be5808c71f10b870297c295ea0abc465e"
+ "ecec75fd59f59c0538ce9e88d72558d15cb251884c2aa59719fe9a91e98462d0"
],
[
[
@@ -305,28 +333,28 @@
"file",
"web-app/src/actions/core.d.ts"
],
- "d649b4823ede60119858b868dac9b122da4a3185391e6b9242ff7f6dc5c25d31"
+ "34b44699906b98ca28050479b66254e64073a403b571a8fd1a227395d27becae"
],
[
[
"file",
"web-app/src/actions/core.js"
],
- "5c4dcdec74fb014a8edbb3d240bcbbfc829e201bce64132598b444db14a2bd45"
+ "f38003d51d9754952bf595a25fdb44580d88af3bcd6658848cf8a339a8240689"
],
[
[
"file",
"web-app/src/actions/index.ts"
],
- "0f294c2f1d50a1473f6d332ef17944e7475c7d83a2180f6e2c9c9aecf25439f4"
+ "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092"
],
[
[
"file",
- "web-app/src/api.js"
+ "web-app/src/api.ts"
],
- "bcdb1fdc97fd622fbc882e4a16a7137e849748d6776b960121df6ccab577a3a9"
+ "ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
],
[
[
@@ -387,16 +415,16 @@
[
[
"file",
- "web-app/src/operations/index.js"
+ "web-app/src/operations/index.ts"
],
- "6ab717db2304b6134073aa71144b213b86f8d68a106549da06e193d18683dd87"
+ "7ec3870a9313ec17d2282f9002da4a5d4da3c096c5fb0927acd008f1bec60bb3"
],
[
[
"file",
"web-app/src/operations/resources.js"
],
- "8bb71b7d7ef3962c28b9db1554500452e7f91b0c9fd898d5cafcdb7229f6db1e"
+ "c08445d84f9931ebc3e4f975017bf88c03b6bee9aa588e945d52fe3aee9c4d70"
],
[
[
@@ -410,21 +438,21 @@
"file",
"web-app/src/queries/core.d.ts"
],
- "f0b289140e92738451be386ca73a2fd1c84e9951eb2f1b9c6c09dfa3079d0c74"
+ "8ac475ed46a373ddc47e37dd557315c9c15abcde527749bb40c08527130e0567"
],
[
[
"file",
"web-app/src/queries/core.js"
],
- "2daf5b414722204281d65e954ce862a6fc586e8907b202800694909d23957c5e"
+ "3ac4a8402a7d6af3eb4a90ec59bcfdd32585f3f2dc6d6bed8a1140cea8fdb7f2"
],
[
[
"file",
"web-app/src/queries/index.d.ts"
],
- "efc70de9916a60e19e0c86aaf955b0be0c999ba5c30139c3b6b98bcc4d382091"
+ "f3a7cf037867cd7f8ce76cbca5e5eb2aacb9ca69933c182bce2f7ff935c4d385"
],
[
[
@@ -447,6 +475,48 @@
],
"103724de16eb74300d9fab366bf73401ffd6bf9018394044c1d4ade22731868e"
],
+ [
+ [
+ "file",
+ "web-app/src/storage.ts"
+ ],
+ "1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/index.ts"
+ ],
+ "cb2e2dc33df8afc0d4453f4322a4e2af92f3345e9622e0416fa87e34d6acb9d8"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/helpers.tsx"
+ ],
+ "6e1def1b1755c23d766a1581200295429238beaaea67ba066ba81e3b7768f8bb"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/setup.ts"
+ ],
+ "1c08b10e428cec3939e0ab269c9a02694e196de7c5dd9f18372424bdccbc5028"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/types.ts"
+ ],
+ "282f6bdef1e7e8846ad53909bbf88c54cd04a0594b77b1e87cb69266de84cc6d"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
+ ],
[
[
"file",
@@ -473,7 +543,7 @@
"file",
"web-app/tsconfig.json"
],
- "27e39dd3e6155ffccdb1d9cb0cba8db7d9e06e10958bee900340a2d9f17400c8"
+ "887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
],
[
[
@@ -487,6 +557,6 @@
"file",
"web-app/vite.config.ts"
],
- "ab5c138ab0ab01f5108c2da9a8bf6b673b79115ca97276f6293da8e3bc0a45b1"
+ "0ab8b3892a5d5d25b85646ef30e8b2487904415021912e68670fab316b2ecf2d"
]
]
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json
index 21bf1e41b..d51f20798 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json
@@ -1 +1 @@
-{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}}
\ No newline at end of file
+{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/.env b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/.env
new file mode 100644
index 000000000..e69de29bb
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json
index 3ec8f33b6..a6fa59748 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json
@@ -1,6 +1,6 @@
{
"dependencies": {
- "@prisma/client": "4.5.0",
+ "@prisma/client": "4.12.0",
"cookie-parser": "~1.4.6",
"cors": "^2.8.5",
"dotenv": "16.0.2",
@@ -10,15 +10,18 @@
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"patch-package": "^6.4.7",
+ "rate-limiter-flexible": "^2.4.1",
"secure-password": "^4.0.0",
+ "superjson": "^1.12.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/express": "^4.17.13",
+ "@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"nodemon": "^2.0.19",
- "prisma": "4.5.0",
+ "prisma": "4.12.0",
"standard": "^17.0.0",
"typescript": "^4.8.4"
},
@@ -35,12 +38,13 @@
"sodium-native": "3.3.0"
}
},
+ "prisma": {},
"private": true,
"scripts": {
"build": "npx tsc",
"build-and-start": "npm run build && npm run start",
- "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
+ "db-seed": "npm run build && NODE_PATH=dist node -r dotenv/config dist/dbSeed.js",
"postinstall": "patch-package",
"standard": "standard",
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js",
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/_types/index.ts
index 27ec89ccc..95efc986b 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/_types/index.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/_types/index.ts
@@ -1,3 +1,6 @@
+import { type Expand } from "../universal/types.js";
+import { type Request, type Response } from 'express'
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import prisma from "../dbClient.js"
import { type _Entity } from "./taggedEntities"
@@ -9,12 +12,21 @@ export type Action = Operation = (
args: Input,
- context: Expand>,
-) => Promise
+ context: Context,
+) => Output | Promise
-type OperationContext = {
- entities: Expand>
-}
+export type Api<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: Context,
+) => void
type EntityMap = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
@@ -23,9 +35,7 @@ type EntityMap = {
type PrismaDelegate = {
}
-// This is a helper type used exclusively for DX purposes. It's a No-op for the
-// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
-// type constructors) to make it more readable for the user.
-//
-// Check this SO answer for details: https://stackoverflow.com/a/57683652
-type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never
+type Context = Expand<{
+ entities: Expand>
+}>
+
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js
index 7ffcd317d..7d87c21e4 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js
@@ -16,7 +16,7 @@ const app = express()
app.use(helmet())
app.use(cors({
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
- origin: config.frontendUrl,
+ origin: config.allowedCORSOrigins,
}))
app.use(logger('dev'))
app.use(express.json())
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js
index c23cf0959..9230b7dc5 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js
@@ -12,17 +12,31 @@ const env = process.env.NODE_ENV || 'development'
const config = {
all: {
env,
+ isDevelopment: env === 'development',
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
frontendUrl: undefined,
+ allowedCORSOrigins: [],
},
- development: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000',
- },
- production: {
- frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL),
- }
+ development: getDevelopmentConfig(),
+ production: getProductionConfig(),
}
const resolvedConfig = merge(config.all, config[env])
export default resolvedConfig
+
+function getDevelopmentConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000';
+ return {
+ frontendUrl,
+ allowedCORSOrigins: '*',
+ }
+}
+
+function getProductionConfig() {
+ const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL);
+ return {
+ frontendUrl,
+ allowedCORSOrigins: [frontendUrl],
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts
similarity index 100%
rename from waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.js
rename to waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbSeed/types.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbSeed/types.ts
new file mode 100644
index 000000000..fe0d5396a
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbSeed/types.ts
@@ -0,0 +1,3 @@
+import type { PrismaClient } from '@prisma/client'
+
+export type DbSeedFn = (prismaClient: PrismaClient) => Promise
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts
index 994904589..bef664aac 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts
@@ -1,4 +1,5 @@
-import { Application } from 'express'
+
+import { type Application } from 'express'
import { Server } from 'http'
export type ServerSetupFn = (context: ServerSetupFnContext) => Promise
@@ -8,5 +9,7 @@ export type ServerSetupFnContext = {
server: Server,
}
-export { Application } from 'express'
+export type { Application } from 'express'
export { Server } from 'http'
+
+
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/universal/types.ts
new file mode 100644
index 000000000..8cadbd740
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/universal/types.ts
@@ -0,0 +1,31 @@
+// This is a helper type used exclusively for DX purposes. It's a No-op for the
+// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
+// type constructors) to make it more readable for the user.
+//
+// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
+export type Expand = T extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : T extends infer O
+ ? { [K in keyof O]: O[K] }
+ : never
+
+// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
+// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
+// details). Until it's fixed, we're using our own type for this.
+//
+// TODO: investigate further. This most likely has something to do with an
+// unsatisfied 'extends' constraints. A mismatch is probably happening with
+// function parameter types and/or return types (check '_ReturnType' below for
+// more).
+export type _Awaited = T extends Promise
+ ? _Awaited
+ : T
+
+// TypeScript's native ReturnType does not work for functions of type '(...args:
+// never[]) => unknown' (and that's what operations currently use).
+//
+// TODO: investigate how to properly specify the 'extends' constraint for function
+// type (i.e., any vs never and unknown) and stick with that. Take DX into
+// consideration.
+export type _ReturnType unknown> =
+ T extends (...args: never[]) => infer R ? R : never
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js
index e07b121be..d6c7ed639 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js
@@ -1,6 +1,10 @@
import Prisma from '@prisma/client'
import HttpError from './core/HttpError.js'
+import { readdir } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from 'url';
+
/**
* Decorator for async express middleware that handles promise rejections.
* @param {Func} middleware - Express middleware function.
@@ -28,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
- message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
+ message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {
@@ -45,3 +49,31 @@ export const prismaErrorToHttpError = (e) => {
}
export const sleep = ms => new Promise(r => setTimeout(r, ms))
+
+export function getDirFromFileUrl(fileUrl) {
+ return fileURLToPath(dirname(fileUrl));
+}
+
+export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) {
+ const pathToDir = join(absoluteDir, relativePath);
+
+ return new Promise((resolve, reject) => {
+ readdir(pathToDir, async (err, files) => {
+ if (err) {
+ return reject(err);
+ }
+ const importPromises = files
+ .filter((file) => file.endsWith(".js") && isWhitelisted(file))
+ .map((file) => import(`${pathToDir}/${file}`));
+ resolve(Promise.all(importPromises));
+ });
+ });
+
+ function isWhitelisted(file) {
+ // No whitelist means all files are whitelisted
+ if (!Array.isArray(whitelist)) {
+ return true;
+ }
+ return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile));
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/tsconfig.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/tsconfig.json
index 562d7839c..8999362fe 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/tsconfig.json
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/tsconfig.json
@@ -5,6 +5,7 @@
"strict": false,
// Overriding this because we want to use top-level await
"module": "esnext",
+ "target": "es2017",
// Enable source map for debugging
"sourceMap": true,
// The remaining settings should match node18/tsconfig.json, but I kept
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/.env b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/.env
new file mode 100644
index 000000000..e69de29bb
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json
index 1d2bdf5e8..5f3d00c32 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json
@@ -12,22 +12,29 @@
]
},
"dependencies": {
- "@prisma/client": "4.5.0",
+ "@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.13.0",
"axios": "^0.27.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
- "react-router-dom": "^5.3.3"
+ "react-router-dom": "^5.3.3",
+ "superjson": "^1.12.2"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^5.16.5",
+ "@testing-library/react": "^12.1.5",
"@tsconfig/vite-react": "^1.0.1",
"@types/react": "^17.0.53",
"@types/react-dom": "^17.0.19",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.0.0",
+ "@vitest/ui": "^0.29.3",
"dotenv": "^16.0.3",
+ "jsdom": "^21.1.1",
+ "msw": "^1.1.0",
"typescript": "^4.9.3",
- "vite": "^4.1.0"
+ "vite": "^4.1.0",
+ "vitest": "^0.29.3"
},
"engineStrict": true,
"engines": {
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.d.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.d.ts
index 8a38c25e3..fa31f329f 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.d.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.d.ts
@@ -1,3 +1,13 @@
-import { Action } from '.'
+import { type Action } from '.'
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createAction (actionRoute: string, entitiesUsed: unknown[]): Action
+export function createAction(
+ actionRoute: string,
+ entitiesUsed: unknown[]
+): ActionFor
+
+type ActionFor = Expand<
+ Action[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendAction = (args: never, context: any) => unknown
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.js
index 3258f0d85..440e90614 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
-export function createAction(actionRoute, entitiesUsed) {
+export function createAction(relativeActionRoute, entitiesUsed) {
+ const actionRoute = makeOperationRoute(relativeActionRoute)
+
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts
index 292f8849b..5e4dfedd1 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts
@@ -5,9 +5,12 @@ import {
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
-import { Query } from '../queries';
+import { type Query } from '../queries';
-export type Action = (args?: Input) => Promise;
+export type Action =
+ [Input] extends [never] ?
+ (args?: unknown) => Promise :
+ (args: Input) => Promise
/**
* An options object passed into the `useAction` hook and used to enhance the
diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts
similarity index 58%
rename from waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.js
rename to waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts
index 98238ee21..09d848be6 100644
--- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts
@@ -1,44 +1,39 @@
-import axios from 'axios'
+import axios, { type AxiosError } from 'axios'
import config from './config'
+import { storage } from './storage'
const api = axios.create({
baseURL: config.apiUrl,
})
-const WASP_APP_AUTH_TOKEN_NAME = "authToken"
+const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
-let authToken = null
-if (window.localStorage) {
- authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
-}
+let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
-export const setAuthToken = (token) => {
- if (typeof token !== 'string') {
- throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
- }
+export function setAuthToken (token: string): void {
authToken = token
- window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
+ storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
}
-export const clearAuthToken = () => {
+export function clearAuthToken(): void {
authToken = undefined
- window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
+ storage.remove(WASP_APP_AUTH_TOKEN_NAME)
}
-export const clearLocalStorage = () => {
+export function removeLocalUserData(): void {
authToken = undefined
- window.localStorage && window.localStorage.clear()
+ storage.clear()
}
-api.interceptors.request.use(request => {
+api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
}
return request
})
-api.interceptors.response.use(undefined, error => {
+api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
}
@@ -50,7 +45,7 @@ api.interceptors.response.use(undefined, error => {
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
-export const handleApiError = (error) => {
+export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
@@ -61,14 +56,23 @@ export const handleApiError = (error) => {
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
- const e = new Error(responseJson?.message || error.message)
- e.statusCode = responseStatusCode
- e.data = responseJson?.data
- throw e
+ throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
+class WaspHttpError extends Error {
+ statusCode: number
+
+ data: unknown
+
+ constructor (statusCode: number, message: string, data: unknown) {
+ super(message)
+ this.statusCode = statusCode
+ this.data = data
+ }
+}
+
export default api
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.js
deleted file mode 100644
index e84d0373a..000000000
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import api, { handleApiError } from '../api.js'
-
-export async function callOperation(operationRoute, args) {
- try {
- const response = await api.post(`/${operationRoute}`, args)
- return response.data
- } catch (error) {
- handleApiError(error)
- }
-}
-
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.ts
new file mode 100644
index 000000000..2d4494b05
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/index.ts
@@ -0,0 +1,22 @@
+import api, { handleApiError } from '../api'
+import { HttpMethod } from '../types'
+import {
+ serialize as superjsonSerialize,
+ deserialize as superjsonDeserialize,
+ } from 'superjson'
+
+export type OperationRoute = { method: HttpMethod, path: string }
+
+export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
+ try {
+ const superjsonArgs = superjsonSerialize(args)
+ const response = await api.post(operationRoute.path, superjsonArgs)
+ return superjsonDeserialize(response.data)
+ } catch (error) {
+ handleApiError(error)
+ }
+}
+
+export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
+ return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js
index 70c0458a4..779cd55a5 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js
@@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
/**
* Remembers that specified query is using specified resources.
* If called multiple times for same query, resources are added, not reset.
- * @param {string} queryCacheKey - Unique key under used to identify query in the cache.
+ * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
* @param {string[]} resources - Names of resources that query is using.
*/
export function addResourcesUsedByQuery(queryCacheKey, resources) {
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.d.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.d.ts
index edde495db..e1bdbe478 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.d.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.d.ts
@@ -1,3 +1,23 @@
-export type Query = (args: Input) => Promise
+import { type Query } from '.'
+import { Route } from '../types';
+import type { Expand, _Awaited, _ReturnType } from '../universal/types'
-export function createQuery (queryRoute: string, entitiesUsed: any[]): Query
+export function createQuery(
+ queryRoute: string,
+ entitiesUsed: any[]
+): QueryFor
+
+export function addMetadataToQuery(
+ query: (...args: any[]) => Promise,
+ metadata: {
+ relativeQueryPath: string;
+ queryRoute: Route;
+ entitiesUsed: string[];
+ },
+): void
+
+type QueryFor = Expand<
+ Query[0], _Awaited<_ReturnType>>
+>
+
+type GenericBackendQuery = (args: never, context: any) => unknown
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.js
index bfe2d90c6..5103db1d8 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.js
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/core.js
@@ -1,10 +1,12 @@
-import { callOperation } from '../operations'
+import { callOperation, makeOperationRoute } from '../operations'
import {
addResourcesUsedByQuery,
getActiveOptimisticUpdates,
} from '../operations/resources'
-export function createQuery(queryRoute, entitiesUsed) {
+export function createQuery(relativeQueryPath, entitiesUsed) {
+ const queryRoute = makeOperationRoute(relativeQueryPath)
+
async function query(queryKey, queryArgs) {
const serverResult = await callOperation(queryRoute, queryArgs)
return getActiveOptimisticUpdates(queryKey).reduce(
@@ -13,8 +15,13 @@ export function createQuery(queryRoute, entitiesUsed) {
)
}
- query.queryCacheKey = [queryRoute]
- addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+ addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
return query
}
+
+export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
+ query.queryCacheKey = [relativeQueryPath]
+ query.route = queryRoute
+ addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/index.d.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/index.d.ts
index 3716204db..c007ff4c9 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/index.d.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/queries/index.d.ts
@@ -1,8 +1,10 @@
import { UseQueryResult } from "@tanstack/react-query";
-export type Query = (args: Input) => Promise
+export type Query = {
+ (queryCacheKey: string[], args: Input): Promise
+}
-export function useQuery (
+export function useQuery (
queryFn: Query ,
queryFnArgs?: Input, options?: any
): UseQueryResult
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/storage.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/storage.ts
new file mode 100644
index 000000000..91e039903
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/storage.ts
@@ -0,0 +1,44 @@
+export type DataStore = {
+ set(key: string, value: unknown): void;
+ get(key: string): unknown;
+ remove(key: string): void;
+ clear(): void;
+};
+
+function createLocalStorageDataStore(prefix: string): DataStore {
+ return {
+ set(key, value) {
+ ensureLocalStorageIsAvailable();
+ localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
+ },
+ get(key) {
+ ensureLocalStorageIsAvailable();
+ const value = localStorage.getItem(`${prefix}:${key}`);
+ try {
+ return value ? JSON.parse(value) : undefined;
+ } catch (e: any) {
+ return undefined;
+ }
+ },
+ remove(key) {
+ ensureLocalStorageIsAvailable();
+ localStorage.removeItem(`${prefix}:${key}`);
+ },
+ clear() {
+ ensureLocalStorageIsAvailable();
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith(prefix)) {
+ localStorage.removeItem(key);
+ }
+ });
+ },
+ };
+}
+
+export const storage = createLocalStorageDataStore('wasp');
+
+function ensureLocalStorageIsAvailable(): void {
+ if (!window.localStorage) {
+ throw new Error('Local storage is not available.');
+ }
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/index.ts
new file mode 100644
index 000000000..f002be83c
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/index.ts
@@ -0,0 +1 @@
+export { renderInContext, mockServer } from './vitest/helpers'
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx
new file mode 100644
index 000000000..655c01a29
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/helpers.tsx
@@ -0,0 +1,93 @@
+import { ReactElement } from 'react'
+import { serialize as superjsonSerialize } from 'superjson'
+import { rest, type ResponseResolver, type RestContext } from 'msw'
+import { setupServer, type SetupServer } from 'msw/node'
+import { BrowserRouter as Router } from 'react-router-dom'
+import { render, RenderResult, cleanup } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { beforeAll, afterEach, afterAll } from 'vitest'
+import { Query } from '../../queries'
+import config from '../../config'
+import { HttpMethod, Route } from '../../types'
+
+export { Route } from '../../types'
+
+export type MockQuery = (
+ query: Query ,
+ resJson: MockOutput
+) => void
+
+export type MockApi = (route: Route, resJson: unknown) => void
+
+// Inspired by the Tanstack React Query helper:
+// https://github.com/TanStack/query/blob/4ae99561ca3383d6de3f4aad656a49ba4a17b57a/packages/react-query/src/__tests__/utils.tsx#L7-L26
+export function renderInContext(ui: ReactElement): RenderResult {
+ const client = new QueryClient()
+ const { rerender, ...result } = render(
+
+ {ui}
+
+ )
+ return {
+ ...result,
+ rerender: (rerenderUi: ReactElement) =>
+ rerender(
+
+ {rerenderUi}
+
+ ),
+ }
+}
+
+export function mockServer(): {
+ server: SetupServer
+ mockQuery: MockQuery
+ mockApi: MockApi
+} {
+ const server: SetupServer = setupServer()
+
+ beforeAll(() => server.listen())
+ afterEach(() => {
+ server.resetHandlers()
+ cleanup()
+ })
+ afterAll(() => server.close())
+
+ const mockQuery: MockQuery = (query, mockData) => {
+ const route = (query as unknown as { route: Route }).route
+ mockRoute(server, route, (_req, res, ctx) =>
+ res(ctx.json(superjsonSerialize(mockData)))
+ )
+ }
+
+ const mockApi: MockApi = (route, mockData) => {
+ mockRoute(server, route, (_req, res, ctx) => res(ctx.json(mockData)))
+ }
+
+ return { server, mockQuery, mockApi }
+}
+
+function mockRoute(
+ server: SetupServer,
+ route: Route,
+ responseHandler: ResponseResolver
+) {
+ if (!Object.values(HttpMethod).includes(route.method)) {
+ throw new Error(
+ `Unsupported query method for mocking: ${
+ route.method
+ }. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`
+ )
+ }
+
+ const url = `${config.apiUrl}${route.path}`
+
+ const handlers: Record[0]> = {
+ [HttpMethod.Get]: rest.get(url, responseHandler),
+ [HttpMethod.Post]: rest.post(url, responseHandler),
+ [HttpMethod.Put]: rest.put(url, responseHandler),
+ [HttpMethod.Delete]: rest.delete(url, responseHandler),
+ }
+
+ server.use(handlers[route.method])
+}
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/setup.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/setup.ts
new file mode 100644
index 000000000..d263e51f4
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/test/vitest/setup.ts
@@ -0,0 +1,4 @@
+import matchers from '@testing-library/jest-dom/matchers'
+import { expect } from 'vitest'
+
+expect.extend(matchers)
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/types.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/types.ts
new file mode 100644
index 000000000..982b766e3
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/types.ts
@@ -0,0 +1,9 @@
+// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
+export enum HttpMethod {
+ Get = 'GET',
+ Post = 'POST',
+ Put = 'PUT',
+ Delete = 'DELETE',
+}
+
+export type Route = { method: HttpMethod; path: string }
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/universal/types.ts
new file mode 100644
index 000000000..8cadbd740
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/universal/types.ts
@@ -0,0 +1,31 @@
+// This is a helper type used exclusively for DX purposes. It's a No-op for the
+// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
+// type constructors) to make it more readable for the user.
+//
+// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
+export type Expand = T extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : T extends infer O
+ ? { [K in keyof O]: O[K] }
+ : never
+
+// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
+// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
+// details). Until it's fixed, we're using our own type for this.
+//
+// TODO: investigate further. This most likely has something to do with an
+// unsatisfied 'extends' constraints. A mismatch is probably happening with
+// function parameter types and/or return types (check '_ReturnType' below for
+// more).
+export type _Awaited = T extends Promise
+ ? _Awaited
+ : T
+
+// TypeScript's native ReturnType does not work for functions of type '(...args:
+// never[]) => unknown' (and that's what operations currently use).
+//
+// TODO: investigate how to properly specify the 'extends' constraint for function
+// type (i.e., any vs never and unknown) and stick with that. Take DX into
+// consideration.
+export type _ReturnType unknown> =
+ T extends (...args: never[]) => infer R ? R : never
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/tsconfig.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/tsconfig.json
index fdeb6c94b..c5012408c 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/tsconfig.json
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "@tsconfig/vite-react/tsconfig.json",
"compilerOptions": {
// Temporary loosen the type checking until we can address all the errors.
+ "jsx": "preserve",
"allowJs": true,
"strict": false
},
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/vite.config.ts
index 4948c6fe7..8b3650d21 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/vite.config.ts
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
@@ -6,9 +7,15 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
+ host: '0.0.0.0',
+ open: true,
},
envPrefix: 'REACT_APP_',
build: {
outDir: 'build',
},
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/vitest/setup.ts'],
+ },
})
diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp
index be1e60e19..f469b19e6 100644
--- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp
+++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp
@@ -1,6 +1,6 @@
app waspCompile {
wasp: {
- version: "^0.9.0"
+ version: "^0.10.0"
},
title: "waspCompile"
}
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest
index cef1241be..d59928573 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest
@@ -16,17 +16,35 @@ waspComplexTest/.wasp/out/server/scripts/universal/validators.mjs
waspComplexTest/.wasp/out/server/scripts/validate-env.mjs
waspComplexTest/.wasp/out/server/src/_types/index.ts
waspComplexTest/.wasp/out/server/src/_types/taggedEntities.ts
-waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.js
+waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts
waspComplexTest/.wasp/out/server/src/actions/types.ts
+waspComplexTest/.wasp/out/server/src/apis/types.ts
waspComplexTest/.wasp/out/server/src/app.js
+waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/index.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts
+waspComplexTest/.wasp/out/server/src/auth/providers/types.ts
+waspComplexTest/.wasp/out/server/src/auth/utils.ts
waspComplexTest/.wasp/out/server/src/config.js
waspComplexTest/.wasp/out/server/src/core/AuthError.js
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/dbClient.js
+waspComplexTest/.wasp/out/server/src/core/auth/validators.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
+waspComplexTest/.wasp/out/server/src/email/core/index.ts
+waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts
+waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts
+waspComplexTest/.wasp/out/server/src/email/core/types.ts
+waspComplexTest/.wasp/out/server/src/email/index.ts
waspComplexTest/.wasp/out/server/src/entities/index.ts
waspComplexTest/.wasp/out/server/src/ext-src/actions/bar.js
+waspComplexTest/.wasp/out/server/src/ext-src/apis.ts
waspComplexTest/.wasp/out/server/src/ext-src/jobs/bar.js
waspComplexTest/.wasp/out/server/src/ext-src/myServerSetupCode.js
waspComplexTest/.wasp/out/server/src/ext-src/queries/bar.js
@@ -37,52 +55,51 @@ waspComplexTest/.wasp/out/server/src/jobs/core/allJobs.js
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspComplexTest/.wasp/out/server/src/jobs/core/simpleJob.js
-waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.js
+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/login.js
waspComplexTest/.wasp/out/server/src/routes/auth/me.js
-waspComplexTest/.wasp/out/server/src/routes/auth/passport/generic/provider.js
-waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/config.js
-waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/configMapping.js
-waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/defaults.js
-waspComplexTest/.wasp/out/server/src/routes/auth/passport/passport.js
-waspComplexTest/.wasp/out/server/src/routes/auth/signup.js
-waspComplexTest/.wasp/out/server/src/routes/auth/utils.js
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
waspComplexTest/.wasp/out/server/src/routes/operations/index.js
waspComplexTest/.wasp/out/server/src/server.ts
waspComplexTest/.wasp/out/server/src/types/index.ts
+waspComplexTest/.wasp/out/server/src/universal/types.ts
waspComplexTest/.wasp/out/server/src/universal/url.ts
waspComplexTest/.wasp/out/server/src/utils.js
waspComplexTest/.wasp/out/server/tsconfig.json
+waspComplexTest/.wasp/out/web-app/.env
waspComplexTest/.wasp/out/web-app/.npmrc
waspComplexTest/.wasp/out/web-app/README.md
waspComplexTest/.wasp/out/web-app/index.html
waspComplexTest/.wasp/out/web-app/netlify.toml
waspComplexTest/.wasp/out/web-app/package.json
waspComplexTest/.wasp/out/web-app/public/favicon.ico
-waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png
waspComplexTest/.wasp/out/web-app/public/manifest.json
waspComplexTest/.wasp/out/web-app/scripts/universal/validators.mjs
waspComplexTest/.wasp/out/web-app/scripts/validate-env.mjs
-waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.js
+waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.ts
waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts
waspComplexTest/.wasp/out/web-app/src/actions/core.js
waspComplexTest/.wasp/out/web-app/src/actions/index.ts
-waspComplexTest/.wasp/out/web-app/src/api.js
-waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx
-waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx
+waspComplexTest/.wasp/out/web-app/src/api.ts
+waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/ForgotPassword.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/ResetPassword.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialButton.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialIcons.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/VerifyEmail.tsx
+waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts
waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx
waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts
-waspComplexTest/.wasp/out/web-app/src/auth/login.js
waspComplexTest/.wasp/out/web-app/src/auth/logout.js
waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx
waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx
-waspComplexTest/.wasp/out/web-app/src/auth/signup.js
-waspComplexTest/.wasp/out/web-app/src/auth/useAuth.js
+waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts
waspComplexTest/.wasp/out/web-app/src/config.js
waspComplexTest/.wasp/out/web-app/src/entities/index.ts
waspComplexTest/.wasp/out/web-app/src/ext-src/App.jsx
@@ -93,16 +110,23 @@ waspComplexTest/.wasp/out/web-app/src/ext-src/vite-env.d.ts
waspComplexTest/.wasp/out/web-app/src/ext-src/waspLogo.png
waspComplexTest/.wasp/out/web-app/src/index.tsx
waspComplexTest/.wasp/out/web-app/src/logo.png
-waspComplexTest/.wasp/out/web-app/src/operations/index.js
+waspComplexTest/.wasp/out/web-app/src/operations/index.ts
waspComplexTest/.wasp/out/web-app/src/operations/resources.js
waspComplexTest/.wasp/out/web-app/src/operations/updateHandlersMap.js
-waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.js
+waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.ts
waspComplexTest/.wasp/out/web-app/src/queries/core.d.ts
waspComplexTest/.wasp/out/web-app/src/queries/core.js
waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts
waspComplexTest/.wasp/out/web-app/src/queries/index.js
waspComplexTest/.wasp/out/web-app/src/queryClient.js
waspComplexTest/.wasp/out/web-app/src/router.jsx
+waspComplexTest/.wasp/out/web-app/src/stitches.config.js
+waspComplexTest/.wasp/out/web-app/src/storage.ts
+waspComplexTest/.wasp/out/web-app/src/test/index.ts
+waspComplexTest/.wasp/out/web-app/src/test/vitest/helpers.tsx
+waspComplexTest/.wasp/out/web-app/src/test/vitest/setup.ts
+waspComplexTest/.wasp/out/web-app/src/types.ts
+waspComplexTest/.wasp/out/web-app/src/universal/types.ts
waspComplexTest/.wasp/out/web-app/src/universal/url.ts
waspComplexTest/.wasp/out/web-app/src/utils.js
waspComplexTest/.wasp/out/web-app/src/vite-env.d.ts
@@ -120,6 +144,7 @@ waspComplexTest/src/client/tsconfig.json
waspComplexTest/src/client/vite-env.d.ts
waspComplexTest/src/client/waspLogo.png
waspComplexTest/src/server/actions/bar.js
+waspComplexTest/src/server/apis.ts
waspComplexTest/src/server/jobs/bar.js
waspComplexTest/src/server/myServerSetupCode.js
waspComplexTest/src/server/queries/bar.js
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server
index d8b848a5b..3009791f3 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server
@@ -1,3 +1,5 @@
GOOGLE_CLIENT_ID=google_client_id
GOOGLE_CLIENT_SECRET=google_client_secret
+DATABASE_URL=mock-database-url
+SENDGRID_API_KEY=sendgrid_api_key
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums
index 8a6637277..63866c13f 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums
@@ -25,7 +25,7 @@
"file",
"server/.env"
],
- "7868f4dd9dc579bb21167f4e4cd5caa2960bf44ebe7d3f9f27a2f9380491d31f"
+ "d9649485a674ce3c6b8d668a1253cde3bd8695cb3d2d3a6b7c573a3e2805c7ec"
],
[
[
@@ -60,7 +60,7 @@
"file",
"server/package.json"
],
- "45dd065b8f3937ad56587759a30dc25fc467783a8f9cdd6e68f15fbed5babdff"
+ "595657debb7d5a82faf58d10e8039b1071b7ed1723d0f47e776db6b5c18d56ed"
],
[
[
@@ -88,7 +88,7 @@
"file",
"server/src/_types/index.ts"
],
- "64552c6b9e03c30d670161bd3912cd8050b9be8255f76a024c0995b9a38b5f55"
+ "5e5d0d6222c4e8bc3df40f8913bcecb43b8b3cab9f3de39a68b35a934f09f873"
],
[
[
@@ -100,9 +100,9 @@
[
[
"file",
- "server/src/actions/MySpecialAction.js"
+ "server/src/actions/MySpecialAction.ts"
],
- "2dce5f6e711a9b7d94ce57c17ec27c5f86149baaf4a2ad9bc4a108756b13145e"
+ "ccc1fe9bc8989fe3ea9e7629d89d9156f55836ccafc2aa51a46fcbf4e424141f"
],
[
[
@@ -111,19 +111,82 @@
],
"225c35a21bce64ec2081013c7aadef5bb7ebbc3bdad617d08390164e150555aa"
],
+ [
+ [
+ "file",
+ "server/src/apis/types.ts"
+ ],
+ "68417d37842d9186a6a9d942d65f8a18f2da8c0a9cf8a0dd5f1a4b087f6d4e20"
+ ],
[
[
"file",
"server/src/app.js"
],
- "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72"
+ "f7df4b76a53a92117e0ddca41edd47961cf20ee6f13cc4d252e11c2a293a6e76"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/config/google.ts"
+ ],
+ "7fbd4267706f1ce75574392c4b763917b7d574554db6966cacb9bac29abcf3df"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/index.ts"
+ ],
+ "c56c888520fb6148883d86ede9e2226f3548d2ca929fb6b89145d69b490c775e"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/oauth/createRouter.ts"
+ ],
+ "63dbe409a2de70c55e3f4c01b5faa1da6d09ac0947ff90133c6c616c57ad75c7"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/oauth/defaults.ts"
+ ],
+ "2bff7ab070b402aac4ea69a950c0ef6ba7e58c27d4727bdf6046961cb7c0dd48"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/oauth/init.ts"
+ ],
+ "7db2dc4736dadede12c9f85a88d31b992fe26ad06d57dde34784a2db54356ee4"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/oauth/types.ts"
+ ],
+ "81c3ff4d945025fc2530b57db7ceecb5c14857e84d98e8c11f045282c25efe37"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/providers/types.ts"
+ ],
+ "9859bf91e0abe0aaadf7b8c74c573607f6085a5414071836cef6ba84b2bebb69"
+ ],
+ [
+ [
+ "file",
+ "server/src/auth/utils.ts"
+ ],
+ "cc5c7e899b9e4389999748f19177f8a392098279f19d99875d09986334d173c8"
],
[
[
"file",
"server/src/config.js"
],
- "99e89fb4d207108caf0afeaf8f364819bac6f6d7c28a0a14b6ae7a4f134aa779"
+ "b5e2d31201460b018e781532b7bb6348cf3f7eb45851a81711a18584c0f4f5ac"
],
[
[
@@ -144,21 +207,77 @@
"file",
"server/src/core/auth.js"
],
- "968abe76a1cf4627b2bce2b9504558dbb1a8e5a50690262d6d4cd9506066ee72"
+ "0ea7ff5b8576c4bd455328a56d60a038c1107eaa2e20cba28437cdfc630ce571"
],
[
[
"file",
"server/src/core/auth/prismaMiddleware.js"
],
- "aede440c2297aa5028e5f387e3392228d8249fb4906aeb95364006aeec039aae"
+ "72352443cb7f3f72e7608270d19f7e541b1b1f233fa995391f253e1585efc71c"
],
[
[
"file",
- "server/src/dbClient.js"
+ "server/src/core/auth/validators.ts"
],
- "5fb53eff5e5eae318e281a8fc1cc433b69688d93c3c82d72415f710a6e75e2af"
+ "d7cfe22168d66e0d346c2aec33573c0346c9f0f5c854d3f204bed5b4c315da87"
+ ],
+ [
+ [
+ "file",
+ "server/src/dbClient.ts"
+ ],
+ "a749686af4e331a0b982e8c2acbfe00aff043340f34d54e80a34d417c6901e56"
+ ],
+ [
+ [
+ "file",
+ "server/src/dbSeed/types.ts"
+ ],
+ "47442c546d286164c2cdb3359deb45b47f115df2ca72bb181b4639bee2059d38"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/core/helpers.ts"
+ ],
+ "b59fd329fc96e47f2839ba0b06ea5e42935e4959d228676be9c13ddb30d9e02c"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/core/index.ts"
+ ],
+ "d524dd9ef27cd311340060411276df0e8ef22db503473f44281832338b954bb7"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/core/providers/dummy.ts"
+ ],
+ "e93a7a02f50c8466f3e8e89255b98bebde598b25f9969ec117b16f07691575ae"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/core/providers/sendgrid.ts"
+ ],
+ "b1a16455eede9723f6ae43c03e628b4129fd96f7300f004f052f1eab31d9b95b"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/core/types.ts"
+ ],
+ "c343f0d87b65d7563816159a88f410b65d78d897822c0bbcd723ca7752e00a20"
+ ],
+ [
+ [
+ "file",
+ "server/src/email/index.ts"
+ ],
+ "c4864d5c83b96a61b1ddfaac7b52c0898f5cff04320c166c8f658b017952ee05"
],
[
[
@@ -174,6 +293,13 @@
],
"83c606a3eee7608155cdb2c2a20a38f851a82987e060ce25b196b467092c4740"
],
+ [
+ [
+ "file",
+ "server/src/ext-src/apis.ts"
+ ],
+ "09c24033466aa3c0caaf923c4260c87f756d6b4c3bf2c53acd75196a85361ee2"
+ ],
[
[
"file",
@@ -247,9 +373,9 @@
[
[
"file",
- "server/src/queries/MySpecialQuery.js"
+ "server/src/queries/MySpecialQuery.ts"
],
- "ecb3145ccbb1b4888db63b5cb000f4d65721d5b6ea99cc670d9f8faaddf0c51d"
+ "00fa7ce8dbb7bc3f581ab6f6400a69c54d5520f5cba64598419e471d3a01f7ef"
],
[
[
@@ -261,93 +387,44 @@
[
[
"file",
- "server/src/routes/auth/index.js"
+ "server/src/routes/apis/index.ts"
],
- "ac6c9f57907b95529955248014d4a0b87d9883afba9f725e8bb6da7e9cdcbaef"
+ "601591c5b0846be03c31ad70185ba6e6592732656843daf91c5ecc9e1b053fe3"
],
[
[
"file",
- "server/src/routes/auth/login.js"
+ "server/src/routes/auth/index.js"
],
- "0a5d88e1a91ceb6a893e286ed2e1167bf418ca9d226341d6ee6dc6c0fac54318"
+ "47fb3317c5707e0d7646bb1c0f6fa8d9c5c0f980ca2d643d226e63b49766cea3"
],
[
[
"file",
"server/src/routes/auth/me.js"
],
- "65ac0f6fe12e13ffeb67b01b62301c4c70e12707985bfa567aea0253649e9151"
- ],
- [
- [
- "file",
- "server/src/routes/auth/passport/generic/provider.js"
- ],
- "bac53b5bb71b55593c97c18dcb26fe05c9fa3487dd7a63ee64940362100853ef"
- ],
- [
- [
- "file",
- "server/src/routes/auth/passport/google/config.js"
- ],
- "40b271cec4039bcd0435e1766b0fdf6954caff6401db1272034848ea5bef97b3"
- ],
- [
- [
- "file",
- "server/src/routes/auth/passport/google/configMapping.js"
- ],
- "bb20100bf23ee29e783f0d486f4b3fc75792742f4b92f9547c4b12216afebbb7"
- ],
- [
- [
- "file",
- "server/src/routes/auth/passport/google/defaults.js"
- ],
- "e390f515ed581c51b75b43e716e346710c17fb1e99470f944de22fc22925a2e1"
- ],
- [
- [
- "file",
- "server/src/routes/auth/passport/passport.js"
- ],
- "8798e7255db79cd528776cc9c72781950f69314277c6eff8b5320e6ffa722a2e"
- ],
- [
- [
- "file",
- "server/src/routes/auth/signup.js"
- ],
- "7e1975a564b8d450f6c411753bfe4ab45d76b209bb28cfc86eb514236a018219"
- ],
- [
- [
- "file",
- "server/src/routes/auth/utils.js"
- ],
- "c2752f5686d06db30aaefe7e7d2bae5e5d988bf9f34e04c262b73a69179521ae"
+ "9a9cb533bb94af63caf448f73a0d0fef8902c8f8d1af411bed2570a32da2fab9"
],
[
[
"file",
"server/src/routes/index.js"
],
- "bd024209f256910dc8450851bf64d5309423e04f34bc5941cfb692df7907aa27"
+ "8adccf8d9ca89d67bac22ee3fac02a4bc94dde696388cadb33962cf89372fd73"
],
[
[
"file",
"server/src/routes/operations/MySpecialAction.js"
],
- "a811906270902af1259bffee936c9628e5aa3235cf9b08cc52b1acd54adebb2c"
+ "19eea333d3f3f559b93b49f34a3a6f474d86b4d8b83c70f4bb456ef44e361eef"
],
[
[
"file",
"server/src/routes/operations/MySpecialQuery.js"
],
- "5e4ef25a29bff0806cb2abef2e9ff8314232a5e505dbec0b9ace56d1c3369237"
+ "f8a7ce6ab0c320f24e76d12c5ba01891d8c408c80f07bbf8a6471fafb29abc91"
],
[
[
@@ -368,7 +445,14 @@
"file",
"server/src/types/index.ts"
],
- "37352cdaeae444c90632fe788d1e49eca3927ef8755702ba95c81c8b90d9f408"
+ "04e01e653bd889436903843cf1dae0a6ba0ecbc1c369beef0e90fae4d0c21127"
+ ],
+ [
+ [
+ "file",
+ "server/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
],
[
[
@@ -382,14 +466,21 @@
"file",
"server/src/utils.js"
],
- "68a5794f55e24b303d81456a1181a3a2cd70773f6ebc4e7a63dac064834aa8e9"
+ "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[
"file",
"server/tsconfig.json"
],
- "c58318dce36bc48fdd1e998af906b5026c87b1d80415653b03a7285acdb56d69"
+ "f2632965c1e3678fcc0e63b83d7e33fea1a9008ef5fd5a2f5e7bf278337c3e02"
+ ],
+ [
+ [
+ "file",
+ "web-app/.env"
+ ],
+ "d9649485a674ce3c6b8d668a1253cde3bd8695cb3d2d3a6b7c573a3e2805c7ec"
],
[
[
@@ -431,7 +522,7 @@
"file",
"web-app/package.json"
],
- "7076213d4eff673d37d271baa15cdda5cd5a647d31944015f52ef72aad558ac8"
+ "106826ffacc2f21e037bb05f08c3dfb8370d429c1d5226624a1a02e698de3ae0"
],
[
[
@@ -440,13 +531,6 @@
],
"1481f03584b46b63182c1f8d26e61a95ce4d8fae032b2b4f3fe5e00ab7e96c23"
],
- [
- [
- "file",
- "web-app/public/images/google-logo-icon.png"
- ],
- "e2087f585c3b213ba537a56c8bc8e6134c69d6fa1a5728d306df56d697b4e7ab"
- ],
[
[
"file",
@@ -471,107 +555,142 @@
[
[
"file",
- "web-app/src/actions/MySpecialAction.js"
+ "web-app/src/actions/MySpecialAction.ts"
],
- "55e4031ed91e5dd8c1aaa5cf64e3295356938966f167c6950d81c9b7a77db787"
+ "5bcc78e0debb14c6af4572fe1c7512e8128f0d66c7bb80bd606cab014e6df8ec"
],
[
[
"file",
"web-app/src/actions/core.d.ts"
],
- "d649b4823ede60119858b868dac9b122da4a3185391e6b9242ff7f6dc5c25d31"
+ "34b44699906b98ca28050479b66254e64073a403b571a8fd1a227395d27becae"
],
[
[
"file",
"web-app/src/actions/core.js"
],
- "5c4dcdec74fb014a8edbb3d240bcbbfc829e201bce64132598b444db14a2bd45"
+ "f38003d51d9754952bf595a25fdb44580d88af3bcd6658848cf8a339a8240689"
],
[
[
"file",
"web-app/src/actions/index.ts"
],
- "0f294c2f1d50a1473f6d332ef17944e7475c7d83a2180f6e2c9c9aecf25439f4"
+ "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092"
],
[
[
"file",
- "web-app/src/api.js"
+ "web-app/src/api.ts"
],
- "bcdb1fdc97fd622fbc882e4a16a7137e849748d6776b960121df6ccab577a3a9"
+ "ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
],
[
[
"file",
- "web-app/src/auth/forms/Login.jsx"
+ "web-app/src/auth/forms/Auth.tsx"
],
- "2a97bf6cb1ba5cd1f271dc83aaac5864a7e5738b72814c7d09bb3e5f70877686"
+ "9f8dde92a52f2b89c2ba1d32ac6025f0d010fa5650b858adba0707760e3b4ba6"
],
[
[
"file",
- "web-app/src/auth/forms/Signup.jsx"
+ "web-app/src/auth/forms/ForgotPassword.tsx"
],
- "712a1513116da68cef5a8dddcf80fbaffa8b733690c52e7c5cc1ef329af591e9"
+ "39eda745fc19798d235cbecf7e755ab1050187d99775dfb06304b06804e5c7fc"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/Login.tsx"
+ ],
+ "788a896a5717cd6e082db23cd5e1f4f46816253e24d6bbe60b2d64fb5a208d3c"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/ResetPassword.tsx"
+ ],
+ "be24461d42102d53c028713146054029950137e79e27906a166a16cf9dd64add"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/Signup.tsx"
+ ],
+ "bea6a9756a546820ccd240aabfa3db3d14faf655f7681c7b9ca0791677556c3a"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/SocialButton.tsx"
+ ],
+ "fb14b39e617bf0cb7eec96428fd2882c2fbcda8bebd453ed00866b3203420043"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/SocialIcons.tsx"
+ ],
+ "18ce4ed1aec9fb0e71d4ce8376d5c42fcec1008d9b16d92dd18d51f34ee189a6"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/VerifyEmail.tsx"
+ ],
+ "2cb996958b1332f383af1265a9fa7dd30d71aaef371cd2ac471f9feb44158568"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/auth/forms/types.ts"
+ ],
+ "601917a95f8218f9db51148bf852a21925ea9806729fe9f8a65a96f84e8ba22f"
],
[
[
"file",
"web-app/src/auth/helpers/Google.jsx"
],
- "7707679f4ef70592429401cf932206b5cfd87dd80d244e3e8c05803658406228"
+ "c6677ed5052cf7dc9aca312935b48dd59eaf22420d581ac1b79c01070d3c109e"
],
[
[
"file",
"web-app/src/auth/helpers/user.ts"
],
- "7ae0fa01ac15b43746877b765bc4e6fd533f302c435b771238ad79afbe923000"
- ],
- [
- [
- "file",
- "web-app/src/auth/login.js"
- ],
- "69b575be2d52e2948983f82c2d3fdabcc9d5fad9fd54c21252e9e7daa0edab32"
+ "e6bc091d8f8520db542f959846ecf528e8a070c5ce989151d00d2f45da4a58a6"
],
[
[
"file",
"web-app/src/auth/logout.js"
],
- "041fe67384d24bf43a61b6ad9160b68309d8d1f5f3dd34f1961c93585d4eff50"
+ "51997d8a52c4253e83b5308672c867dbbe9d4e3680fc4e8e5939db28f0a5b70d"
],
[
[
"file",
"web-app/src/auth/pages/OAuthCodeExchange.jsx"
],
- "bb202daa3b41c8288252d56a74ed78bed7129d7c96be96171cd516795d0ff71b"
+ "180e07f82c8290f9cae71e0aef97b40cf1b72a5a5fb58317feaf4cf66e629e42"
],
[
[
"file",
"web-app/src/auth/pages/createAuthRequiredPage.jsx"
],
- "796720e403436139720fa72b941bc5fcf190b05777693c62d2b5f638e872badc"
+ "eb3eced8b273e539f1f0d06a4f99194237e414d208136ac5ac3da6e3c52c2566"
],
[
[
"file",
- "web-app/src/auth/signup.js"
+ "web-app/src/auth/useAuth.ts"
],
- "e2943a925604b86cb1eba6c8d5a99c6892d8d3b55940c656233aa1163a622a6d"
- ],
- [
- [
- "file",
- "web-app/src/auth/useAuth.js"
- ],
- "6ab962413a3103fa4e2cb8f7d4751ee93da484f9312c007e0b4d28bfa130a367"
+ "e3cd75c8a68c29a17a9ca5c5c8e86c03229c2323cf62cbaf70519fe8001cefa4"
],
[
[
@@ -646,16 +765,16 @@
[
[
"file",
- "web-app/src/operations/index.js"
+ "web-app/src/operations/index.ts"
],
- "6ab717db2304b6134073aa71144b213b86f8d68a106549da06e193d18683dd87"
+ "7ec3870a9313ec17d2282f9002da4a5d4da3c096c5fb0927acd008f1bec60bb3"
],
[
[
"file",
"web-app/src/operations/resources.js"
],
- "8bb71b7d7ef3962c28b9db1554500452e7f91b0c9fd898d5cafcdb7229f6db1e"
+ "c08445d84f9931ebc3e4f975017bf88c03b6bee9aa588e945d52fe3aee9c4d70"
],
[
[
@@ -667,30 +786,30 @@
[
[
"file",
- "web-app/src/queries/MySpecialQuery.js"
+ "web-app/src/queries/MySpecialQuery.ts"
],
- "277428b1579f92eb2fb04b323fa7b9c0acaad935d4f3a451c72694aee8605a32"
+ "cf9bc21326f94f4359c66a373324290d73c6b71ff335210b7fddfa3afaa062d6"
],
[
[
"file",
"web-app/src/queries/core.d.ts"
],
- "f0b289140e92738451be386ca73a2fd1c84e9951eb2f1b9c6c09dfa3079d0c74"
+ "8ac475ed46a373ddc47e37dd557315c9c15abcde527749bb40c08527130e0567"
],
[
[
"file",
"web-app/src/queries/core.js"
],
- "2daf5b414722204281d65e954ce862a6fc586e8907b202800694909d23957c5e"
+ "3ac4a8402a7d6af3eb4a90ec59bcfdd32585f3f2dc6d6bed8a1140cea8fdb7f2"
],
[
[
"file",
"web-app/src/queries/index.d.ts"
],
- "efc70de9916a60e19e0c86aaf955b0be0c999ba5c30139c3b6b98bcc4d382091"
+ "f3a7cf037867cd7f8ce76cbca5e5eb2aacb9ca69933c182bce2f7ff935c4d385"
],
[
[
@@ -711,7 +830,56 @@
"file",
"web-app/src/router.jsx"
],
- "a92e5d20f1e2bbb7b4cbfae12e145338ddf34c38823c4b49c954da3c9b623157"
+ "5c34b729d2244c792086224a6fae18c7bdfc029098a1fff9fe1685ba40af9d9b"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/stitches.config.js"
+ ],
+ "7de37836b80021870f286ff14d275e2ca7a1c2aa113ba5a5624ed0c77e178f76"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/storage.ts"
+ ],
+ "1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/index.ts"
+ ],
+ "cb2e2dc33df8afc0d4453f4322a4e2af92f3345e9622e0416fa87e34d6acb9d8"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/helpers.tsx"
+ ],
+ "6e1def1b1755c23d766a1581200295429238beaaea67ba066ba81e3b7768f8bb"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/test/vitest/setup.ts"
+ ],
+ "1c08b10e428cec3939e0ab269c9a02694e196de7c5dd9f18372424bdccbc5028"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/types.ts"
+ ],
+ "282f6bdef1e7e8846ad53909bbf88c54cd04a0594b77b1e87cb69266de84cc6d"
+ ],
+ [
+ [
+ "file",
+ "web-app/src/universal/types.ts"
+ ],
+ "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884"
],
[
[
@@ -739,7 +907,7 @@
"file",
"web-app/tsconfig.json"
],
- "27e39dd3e6155ffccdb1d9cb0cba8db7d9e06e10958bee900340a2d9f17400c8"
+ "887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474"
],
[
[
@@ -753,6 +921,6 @@
"file",
"web-app/vite.config.ts"
],
- "ab5c138ab0ab01f5108c2da9a8bf6b673b79115ca97276f6293da8e3bc0a45b1"
+ "0ab8b3892a5d5d25b85646ef30e8b2487904415021912e68670fab316b2ecf2d"
]
]
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/package.json
index 6ad25b078..9d457f080 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/package.json
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/package.json
@@ -1,8 +1,8 @@
{
"dependencies": {
- "@prisma/client": "^4.5.0"
+ "@prisma/client": "^4.12.0"
},
"devDependencies": {
- "prisma": "^4.5.0"
+ "prisma": "^4.12.0"
}
}
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json
index 50da43704..73aa6d772 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json
@@ -1 +1 @@
-{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.0.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}}
\ No newline at end of file
+{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^12.1.5"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env
index d8b848a5b..31fd552be 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env
@@ -1,3 +1,4 @@
-GOOGLE_CLIENT_ID=google_client_id
-GOOGLE_CLIENT_SECRET=google_client_secret
-
+GOOGLE_CLIENT_ID="google_client_id"
+GOOGLE_CLIENT_SECRET="google_client_secret"
+DATABASE_URL="mock-database-url"
+SENDGRID_API_KEY="sendgrid_api_key"
\ No newline at end of file
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json
index 288a27ec8..28c49592d 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
- "@prisma/client": "4.5.0",
+ "@prisma/client": "4.12.0",
+ "@sendgrid/mail": "^7.7.0",
"cookie-parser": "~1.4.6",
"cors": "^2.8.5",
"dotenv": "16.0.2",
@@ -12,18 +13,21 @@
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"patch-package": "^6.4.7",
- "pg-boss": "^8.0.0",
+ "pg-boss": "^8.4.2",
+ "rate-limiter-flexible": "^2.4.1",
"react-redux": "^7.1.3",
"redux": "^4.0.5",
"secure-password": "^4.0.0",
+ "superjson": "^1.12.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/express": "^4.17.13",
+ "@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"nodemon": "^2.0.19",
- "prisma": "4.5.0",
+ "prisma": "4.12.0",
"standard": "^17.0.0",
"typescript": "^4.8.4"
},
@@ -40,12 +44,13 @@
"sodium-native": "3.3.0"
}
},
+ "prisma": {},
"private": true,
"scripts": {
"build": "npx tsc",
"build-and-start": "npm run build && npm run start",
- "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
+ "db-seed": "npm run build && NODE_PATH=dist node -r dotenv/config dist/dbSeed.js",
"postinstall": "patch-package",
"standard": "standard",
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js",
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts
index ce253db6c..a8788166a 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts
@@ -1,3 +1,6 @@
+import { type Expand } from "../universal/types.js";
+import { type Request, type Response } from 'express'
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import prisma from "../dbClient.js"
import { type User } from "../entities"
import { type _Entity } from "./taggedEntities"
@@ -16,23 +19,39 @@ export type AuthenticatedAction =
type AuthenticatedOperation = (
args: Input,
- context: Expand & {
- // TODO: This type must match the logic in core/auth.js (if we remove the
- // password field from the object there, we must do the same here). Ideally,
- // these two things would live in the same place:
- // https://github.com/wasp-lang/wasp/issues/965
- user: Omit
- }>,
-) => Promise
+ context: ContextWithUser,
+) => Output | Promise
+
+export type AuthenticatedApi<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: ContextWithUser,
+) => void
type Operation = (
args: Input,
- context: Expand>,
-) => Promise
+ context: Context,
+) => Output | Promise
-type OperationContext = {
- entities: Expand>
-}
+export type Api<
+ Entities extends _Entity[],
+ Params extends ExpressParams,
+ ResBody,
+ ReqBody,
+ ReqQuery extends ExpressQuery,
+ Locals extends Record
+> = (
+ req: Request,
+ res: Response,
+ context: Context,
+) => void
type EntityMap = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
@@ -43,9 +62,14 @@ type PrismaDelegate = {
"SocialLogin": typeof prisma.socialLogin,
}
-// This is a helper type used exclusively for DX purposes. It's a No-op for the
-// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
-// type constructors) to make it more readable for the user.
-//
-// Check this SO answer for details: https://stackoverflow.com/a/57683652
-type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never
+type Context = Expand<{
+ entities: Expand>
+}>
+
+type ContextWithUser = Expand & { user?: SanitizedUser}>
+
+// TODO: This type must match the logic in core/auth.js (if we remove the
+// password field from the object there, we must do the same here). Ideally,
+// these two things would live in the same place:
+// https://github.com/wasp-lang/wasp/issues/965
+export type SanitizedUser = Omit
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts
similarity index 73%
rename from waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.js
rename to waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts
index 5de12ac58..32b941001 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.js
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts
@@ -4,10 +4,12 @@ import { foo } from '../ext-src/actions/bar.js'
export default async function (args, context) {
- return foo(args, {
+ return (foo as any)(args, {
...context,
entities: {
User: prisma.user,
},
})
}
+
+export type MySpecialAction = typeof foo
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/apis/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/apis/types.ts
new file mode 100644
index 000000000..8d6aa3cb0
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/apis/types.ts
@@ -0,0 +1,44 @@
+
+import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
+
+export { ParamsDictionary as ExpressParams, Query as ExpressQuery } from 'express-serve-static-core'
+
+import {
+ type Api,
+ type AuthenticatedApi,
+} from '../_types'
+
+export type FooBar<
+ P extends ExpressParams = ExpressParams,
+ ResBody = any,
+ ReqBody = any,
+ ReqQuery extends ExpressQuery = ExpressQuery,
+ Locals extends Record = Record
+> =
+ AuthenticatedApi<
+ [
+ ],
+ P,
+ ResBody,
+ ReqBody,
+ ReqQuery,
+ Locals
+ >
+
+export type FooBaz<
+ P extends ExpressParams = ExpressParams,
+ ResBody = any,
+ ReqBody = any,
+ ReqQuery extends ExpressQuery = ExpressQuery,
+ Locals extends Record = Record
+> =
+ Api<
+ [
+ ],
+ P,
+ ResBody,
+ ReqBody,
+ ReqQuery,
+ Locals
+ >
+
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/app.js
index 7ffcd317d..7d87c21e4 100644
--- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/app.js
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/app.js
@@ -16,7 +16,7 @@ const app = express()
app.use(helmet())
app.use(cors({
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
- origin: config.frontendUrl,
+ origin: config.allowedCORSOrigins,
}))
app.use(logger('dev'))
app.use(express.json())
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts
new file mode 100644
index 000000000..2f5190e64
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts
@@ -0,0 +1,29 @@
+
+import { createRouter } from "../oauth/createRouter.js";
+import { makeOAuthInit } from "../oauth/init.js";
+
+import type { ProviderConfig } from "../types.js";
+import type { OAuthConfig } from "../oauth/types.js";
+
+import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js'
+const _waspUserDefinedConfigFn = undefined
+
+const _waspOAuthConfig: OAuthConfig = {
+ clientID: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ scope: ['"profile"'],
+};
+
+const _waspConfig: ProviderConfig = {
+ id: "google",
+ displayName: "Google",
+ init: makeOAuthInit({
+ npmPackage: 'passport-google-oauth20',
+ getUserFieldsFn: _waspGetUserFieldsFn,
+ userDefinedConfigFn: _waspUserDefinedConfigFn,
+ oAuthConfig: _waspOAuthConfig,
+ }),
+ createRouter,
+}
+
+export default _waspConfig;
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts
new file mode 100644
index 000000000..877ddc834
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts
@@ -0,0 +1,31 @@
+
+import { Router } from "express";
+
+import { getDirFromFileUrl, importJsFilesFromDir } from "../../utils.js";
+
+import { ProviderConfig } from "./types";
+
+const allowedConfigs = [
+ "google.js",
+];
+const providers = await importProviders(allowedConfigs);
+
+const router = Router();
+
+for (const provider of providers) {
+ const { init, createRouter } = provider;
+ const initData = init
+ ? await init(provider)
+ : undefined;
+ const providerRouter = createRouter(provider, initData);
+ router.use(`/${provider.id}`, providerRouter);
+ console.log(`๐ "${provider.displayName}" auth initialized`)
+}
+
+export default router;
+
+async function importProviders(providerConfigs: string[]): Promise {
+ const currentExecutionDir = getDirFromFileUrl(import.meta.url);
+ const providers = await importJsFilesFromDir(currentExecutionDir, "./config", providerConfigs);
+ return providers.map((provider) => provider.default);
+}
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts
new file mode 100644
index 000000000..85a04a93c
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts
@@ -0,0 +1,86 @@
+
+import { Router } from "express"
+import passport from "passport"
+import { v4 as uuidv4 } from 'uuid'
+
+import prisma from '../../../dbClient.js'
+import waspServerConfig from '../../../config.js'
+import { sign } from '../../../core/auth.js'
+import { authConfig, contextWithUserEntity, createUser } from "../../utils.js"
+
+import type { User } from '../../../entities';
+import type { ProviderConfig, RequestWithWasp } from "../types.js"
+import type { GetUserFieldsFn } from "./types.js"
+import { handleRejection } from "../../../utils.js"
+
+// For oauth providers, we have an endpoint /login to get the auth URL,
+// and the /callback endpoint which is used to get the actual access_token and the user info.
+export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn: GetUserFieldsFn }) {
+ const { passportStrategyName, getUserFieldsFn } = initData;
+
+ const router = Router();
+
+ // Constructs a provider OAuth URL and redirects browser to start sign in flow.
+ router.get('/login', passport.authenticate(passportStrategyName, { session: false }));
+
+ // Validates the OAuth code from the frontend, via server-to-server communication
+ // with provider. If valid, provides frontend a response containing the JWT.
+ // NOTE: `addProviderProfileToRequest` is invoked as part of the `passport.authenticate`
+ // call, before the final route handler callback. This is how we gain access to `req.wasp.providerProfile`.
+ router.get('/callback',
+ passport.authenticate(passportStrategyName, {
+ session: false,
+ failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
+ }),
+ handleRejection(async function (req: RequestWithWasp, res) {
+ const providerProfile = req?.wasp?.providerProfile;
+
+ if (!providerProfile) {
+ throw new Error(`Missing ${provider.displayName} provider profile on request. This should not happen! Please contact Wasp.`);
+ } else if (!providerProfile.id) {
+ throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`);
+ }
+
+ // Wrap call to getUserFieldsFn so we can invoke only if needed.
+ const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile });
+ // TODO: In the future we could make this configurable, possibly associating an external account
+ // with the currently logged in account, or by some DB lookup.
+ const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields);
+
+ const token = await sign(user.id);
+ res.json({ token });
+ })
+ )
+
+ return router;
+}
+
+async function findOrCreateUserByExternalAuthAssociation(
+ provider: string,
+ providerId: string,
+ getUserFields: () => ReturnType,
+): Promise {
+ // Attempt to find a User by an external auth association.
+ const externalAuthAssociation = await prisma.socialLogin.findFirst({
+ where: { provider, providerId },
+ include: { user: true }
+ })
+
+ if (externalAuthAssociation) {
+ return externalAuthAssociation.user
+ }
+
+ // No external auth association linkage found. Create a new User using details from
+ // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User.
+ const userFields = await getUserFields()
+ const userAndExternalAuthAssociation = {
+ ...userFields,
+ // TODO: Decouple social from usernameAndPassword auth.
+ password: uuidv4(),
+ externalAuthAssociations: {
+ create: [{ provider, providerId }]
+ }
+ }
+
+ return createUser(userAndExternalAuthAssociation)
+}
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts
new file mode 100644
index 000000000..ad3df3f41
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts
@@ -0,0 +1,6 @@
+import { generateAvailableDictionaryUsername } from '../../../core/auth.js'
+
+export async function getUserFieldsFn(_context, _args) {
+ const username = await generateAvailableDictionaryUsername()
+ return { username }
+}
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts
new file mode 100644
index 000000000..3c9105992
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts
@@ -0,0 +1,76 @@
+import passport from "passport";
+
+import waspServerConfig from '../../../config.js';
+
+import type { InitData, ProviderConfig, RequestWithWasp } from "../types.js";
+import type { OAuthConfig, GetUserFieldsFn, UserDefinedConfigFn } from "./types.js";
+
+export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage, oAuthConfig }: OAuthImports) {
+ return async function init(provider: ProviderConfig): Promise {
+ const userDefinedConfig = userDefinedConfigFn
+ ? userDefinedConfigFn()
+ : {};
+ const ProviderStrategy = await import(npmPackage);
+
+ const passportStrategyName = `wasp${provider.id}LoginStrategy`;
+ const requiredConfig = {
+ clientID: oAuthConfig.clientID,
+ clientSecret: oAuthConfig.clientSecret,
+ scope: oAuthConfig.scope,
+ callbackURL: `${waspServerConfig.frontendUrl}/auth/login/${provider.id}`,
+ passReqToCallback: true
+ };
+
+ const config = {
+ ...requiredConfig,
+ ...userDefinedConfig,
+ };
+ ensureValidConfig(provider, config);
+
+ const passportStrategy = new ProviderStrategy.default(
+ config,
+ addProviderProfileToRequest
+ );
+ passport.use(passportStrategyName, passportStrategy);
+
+ return {
+ passportStrategyName,
+ getUserFieldsFn,
+ };
+ }
+}
+
+// This function is invoked after we successfully exchange the one-time-use OAuth code for a real provider API token.
+// This token was used to get the provider profile information supplied as a parameter.
+// We add the provider profile to the request for downstream use.
+async function addProviderProfileToRequest(
+ req: RequestWithWasp,
+ _accessToken: string,
+ _refreshToken: string,
+ providerProfile: { [key: string]: any },
+ done: any,
+) {
+ req.wasp = { ...req.wasp, providerProfile };
+ done(null, {});
+}
+
+function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void {
+ if (!config.clientID) {
+ throw new Error(`The ${provider.displayName} auth provider requires clientID provided via env varibales.`)
+ }
+
+ if (!config.clientSecret) {
+ throw new Error(`The ${provider.displayName} auth provider requires clientSecret provided via env varibales.`)
+ }
+
+ if (!config.scope || !Array.isArray(config.scope)) {
+ throw new Error(`The ${provider.displayName} auth provider requires scope.`)
+ }
+}
+
+export type OAuthImports = {
+ npmPackage: string;
+ userDefinedConfigFn?: UserDefinedConfigFn;
+ getUserFieldsFn: GetUserFieldsFn;
+ oAuthConfig: OAuthConfig;
+};
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts
new file mode 100644
index 000000000..1589e8887
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts
@@ -0,0 +1,18 @@
+
+import type { Prisma } from "@prisma/client"
+import { contextWithUserEntity } from '../../utils.js'
+
+export type OAuthConfig = {
+ clientID?: string;
+ clientSecret?: string;
+ scope?: string[];
+}
+
+export type CreateOAuthUser = Omit
+
+export type UserDefinedConfigFn = () => { [key: string]: any }
+
+export type GetUserFieldsFn = (
+ context: typeof contextWithUserEntity,
+ args: { profile: { [key: string]: any } },
+) => Promise
diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts
new file mode 100644
index 000000000..0718ea5b3
--- /dev/null
+++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts
@@ -0,0 +1,19 @@
+import type { Router, Request } from "express"
+
+export type ProviderConfig = {
+ // Unique provider identifier, used as part of URL paths
+ id: string;
+ displayName: string;
+ // Each provider config can have an init method which is ran on setup time
+ // e.g. for oAuth providers this is the time when the Passport strategy is registered.
+ init?(provider: ProviderConfig): Promise