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.


license - language - build status + latest release discord

------

- {= 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 && ( + Your Company + )} + {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 ( -
    -

    Username

    - setUsernameFieldVal(e.target.value)} - /> -

    Password

    - setPasswordFieldVal(e.target.value)} - /> -
    - -
    -
    - ) -} - -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 ( -
    -

    Username

    - setUsernameFieldVal(e.target.value)} - /> -

    Password

    - setPasswordFieldVal(e.target.value)} - /> -
    - -
    -
    - ) -} - -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; + // 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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts new file mode 100644 index 000000000..038a3d5f6 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts @@ -0,0 +1,80 @@ +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 User } from '../entities/index.js' +import waspServerConfig from '../config.js'; +import { type Prisma } from '@prisma/client'; + +type UserId = User['id'] + +export const contextWithUserEntity = { + entities: { + User: prisma.user + } +} + +export const authConfig = { + failureRedirectPath: "/login", + successRedirectPath: "/", +} + +export async function findUserBy(where: { [key in K]: User[K] }): Promise { + return prisma.user.findUnique({ where }); +} + +export async function createUser(data: Prisma.UserCreateInput): Promise { + try { + return await prisma.user.create({ data }) + } catch (e) { + rethrowPossiblePrismaError(e); + } +} + +export async function deleteUser(user: User): Promise { + try { + return await prisma.user.delete({ where: { id: user.id } }) + } catch (e) { + rethrowPossiblePrismaError(e); + } +} + +export async function createAuthToken(user: User): 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); +} + + +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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js index ec8ffa568..5c6590be2 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js @@ -12,26 +12,40 @@ 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: [], auth: { jwtSecret: undefined } }, - 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: '*', auth: { jwtSecret: 'DEVJWTSECRET' } - }, - production: { - frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL), + } +} + +function getProductionConfig() { + const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL); + return { + frontendUrl, + allowedCORSOrigins: [frontendUrl], auth: { jwtSecret: process.env.JWT_SECRET } } } - -const resolvedConfig = merge(config.all, config[env]) -export default resolvedConfig diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js index d8bca1d5d..017da4e83 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js @@ -6,6 +6,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) @@ -32,7 +33,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 } @@ -40,7 +41,7 @@ const auth = handleRejection(async (req, res, next) => { const user = await prisma.user.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 @@ -51,7 +52,7 @@ const auth = handleRejection(async (req, res, next) => { req.user = userView } else { - return res.status(401).send() + throwInvalidCredentialsError() } next() @@ -65,11 +66,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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js index d7cc7b83b..53e60c5e1 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js @@ -58,18 +58,17 @@ export const registerAuthMiddleware = (prismaClient) => { registerPasswordHashing(prismaClient) } +const userValidations = [] +userValidations.push({ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username }) +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) }) + 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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/validators.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/validators.ts new file mode 100644 index 000000000..bfefc5fb7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts similarity index 98% rename from waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.js rename to waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts index 30e54cbee..c0720735e 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts @@ -1,16 +1,12 @@ import Prisma from '@prisma/client' - import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js' - const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - registerAuthMiddleware(prismaClient) - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbSeed/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbSeed/types.ts new file mode 100644 index 000000000..fe0d5396a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts new file mode 100644 index 000000000..3a0cdcd45 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts @@ -0,0 +1,24 @@ +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; +} + +export function getDefaultFromField(): EmailFromField { + return { + email: "hello@itsme.com", + name: "Hello", + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts new file mode 100644 index 000000000..f3706274c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts @@ -0,0 +1 @@ +export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid.js"; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts new file mode 100644 index 000000000..b4b3ef045 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts new file mode 100644 index 000000000..3ce8d39cd --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts new file mode 100644 index 000000000..9a2440038 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts @@ -0,0 +1,39 @@ +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 = { + from?: EmailFromField; + to: string; + subject: string; + text: string; + html: string; +}; + +export type EmailFromField = { + name?: string; + email: string; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts new file mode 100644 index 000000000..47f4e8e64 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts @@ -0,0 +1,16 @@ +import { initEmailSender } from "./core/index.js"; + +import waspServerConfig from '../config.js'; +import { initDummyEmailSender } from "./core/providers/dummy.js"; + +const emailProvider = { + type: "sendgrid", + apiKey: process.env.SENDGRID_API_KEY, +} as const; + +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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/ext-src/apis.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/ext-src/apis.ts new file mode 100644 index 000000000..3edd700fb --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/ext-src/apis.ts @@ -0,0 +1,9 @@ +import { FooBar, FooBaz } from '../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!' }) +} + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts similarity index 68% rename from waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.js rename to waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts index 22d72b40f..28a5c5a22 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts @@ -4,10 +4,12 @@ import { foo } from '../ext-src/queries/bar.js' export default async function (args, context) { - return foo(args, { - ...context, + return (foo as any)(args, { + ...context, entities: { User: prisma.user, }, }) } + +export type MySpecialQuery = typeof foo diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/apis/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/apis/index.ts new file mode 100644 index 000000000..1be585404 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/apis/index.ts @@ -0,0 +1,45 @@ +import express from 'express' +import prisma from '../../dbClient.js' +import { handleRejection } from '../../utils.js' +import auth from '../../core/auth.js' +import { type SanitizedUser } from '../../_types' + +import { fooBar } from '../../ext-src/apis.js' +import { fooBaz } from '../../ext-src/apis.js' + +const router = express.Router() + +router.get( + '/foo/bar', + auth, + handleRejection( + ( + req: Parameters[0] & { user: SanitizedUser }, + res: Parameters[1], + ) => { + const context = { + user: req.user, + entities: { + }, + } + return fooBar(req, res, context) + } + ) +) +router.get( + '/foo/baz', + handleRejection( + ( + req: Parameters[0], + res: Parameters[1], + ) => { + const context = { + entities: { + }, + } + return fooBaz(req, res, context) + } + ) +) + +export default router diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js index 621060d88..0fe478cd8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js @@ -1,18 +1,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' -import passportAuth from './passport/passport.js' +import providersRouter from '../../auth/providers/index.js' const router = express.Router() -router.post('/login', login) -router.post('/signup', signup) router.get('/me', auth, me) - -router.use('/external', passportAuth) +router.use('/', providersRouter) export default router diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/login.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/login.js deleted file mode 100644 index 280c6fa3c..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/login.js +++ /dev/null @@ -1,40 +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.user.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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js index ec9de4e43..e84aff221 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js @@ -1,9 +1,11 @@ +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.user) { - return res.json(req.user) + return res.json(superjsonSerialize(req.user)) } else { - return res.status(401).send() + throwInvalidCredentialsError() } }) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/generic/provider.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/generic/provider.js deleted file mode 100644 index 694a83351..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/config.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/config.js deleted file mode 100644 index c03b2488d..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/configMapping.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/configMapping.js deleted file mode 100644 index 0fd49d545..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/configMapping.js +++ /dev/null @@ -1,4 +0,0 @@ - -export { configFn } from './defaults.js' - -export { getUserFieldsFn } from './defaults.js' diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/defaults.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/defaults.js deleted file mode 100644 index 46706a509..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/passport.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/passport.js deleted file mode 100644 index d8882cf07..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/passport/passport.js +++ /dev/null @@ -1,19 +0,0 @@ -import express from 'express' -import { initRouter } from './generic/provider.js' - -const providerMap = new Map(); -providerMap.set('google', { npmPackage: 'passport-google-oauth20', passportImportPath: './google/config.js' }) - -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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/signup.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/signup.js deleted file mode 100644 index 759524dd4..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/signup.js +++ /dev/null @@ -1,22 +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.user.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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/utils.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/utils.js deleted file mode 100644 index 165f44bb2..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/utils.js +++ /dev/null @@ -1,41 +0,0 @@ - -import { v4 as uuidv4 } from 'uuid' - -import prisma from '../../dbClient.js' - -export const contextWithUserEntity = { - entities: { - User: prisma.user - } -} - -export const authConfig = { - failureRedirectPath: "/login", - successRedirectPath: "/", -} - -export async function findOrCreateUserByExternalAuthAssociation(provider, providerId, getUserFields) { - // 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. - // 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.user.create({ data: userAndExternalAuthAssociation }) -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/index.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/index.js index af714d173..bdc8357eb 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/index.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/index.js @@ -1,6 +1,7 @@ import express from 'express' import operations from './operations/index.js' import auth from './auth/index.js' +import apis from './apis/index.js' const router = express.Router() @@ -11,5 +12,7 @@ router.get('/', function (req, res, next) { router.use('/auth', auth) router.use('/operations', operations) +// Keep user-defined api routes last so they cannot override our routes. +router.use(apis) export default router diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js index 42790af78..c0c59ddf6 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js @@ -1,14 +1,19 @@ +import { + deserialize as superjsonDeserialize, + serialize as superjsonSerialize, +} from 'superjson' import { handleRejection } from '../../utils.js' - import MySpecialAction from '../../actions/MySpecialAction.js' export default handleRejection(async (req, res) => { - const args = req.body || {} + const args = (req.body && superjsonDeserialize(req.body)) || {} const context = { user: req.user } + const result = await MySpecialAction(args, context) - res.json(result) + const serializedResult = superjsonSerialize(result) + res.json(serializedResult) }) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js index fe55e8dfc..673db1431 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js @@ -1,14 +1,18 @@ +import { + deserialize as superjsonDeserialize, + serialize as superjsonSerialize, +} from 'superjson' import { handleRejection } from '../../utils.js' - import MySpecialQuery from '../../queries/MySpecialQuery.js' export default handleRejection(async (req, res) => { - const args = req.body || {} + const args = (req.body && superjsonDeserialize(req.body)) || {} const context = { user: req.user } const result = await MySpecialQuery(args, context) - res.json(result) + const serializedResult = superjsonSerialize(result) + res.json(serializedResult) }) 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 994904589..6ac9afb13 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,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,8 @@ export type ServerSetupFnContext = { server: Server, } -export { Application } from 'express' +export type { Application } from 'express' export { Server } from 'http' + +export type { GetUserFieldsFn } from '../auth/providers/oauth/types'; + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js index e07b121be..d6c7ed639 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/server/tsconfig.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/tsconfig.json index 562d7839c..8999362fe 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env new file mode 100644 index 000000000..31fd552be --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env @@ -0,0 +1,4 @@ +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/web-app/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json index 55e69dc4a..15fde2ec1 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json @@ -12,24 +12,32 @@ ] }, "dependencies": { - "@prisma/client": "4.5.0", + "@prisma/client": "4.12.0", + "@stitches/react": "^1.2.8", "@tanstack/react-query": "^4.13.0", "axios": "^0.27.2", "react": "^17.0.2", "react-dom": "^17.0.2", "react-redux": "^7.1.3", "react-router-dom": "^5.3.3", - "redux": "^4.0.5" + "redux": "^4.0.5", + "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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png deleted file mode 100644 index a4a9918da..000000000 Binary files a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png and /dev/null differ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.js deleted file mode 100644 index e792cd00f..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createAction } from './core' - -export default createAction( - 'operations/my-special-action', - ['User'], -) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.ts new file mode 100644 index 000000000..74c31591d --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.ts @@ -0,0 +1,9 @@ +import { createAction } from './core' +import { MySpecialAction } from '../../../server/src/actions/MySpecialAction' + +const action = createAction( + 'operations/my-special-action', + ['"User"'], +) + +export default action diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts index 8a38c25e3..fa31f329f 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.js index 3258f0d85..440e90614 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/core.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts index 292f8849b..5e4dfedd1 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/data/Generator/templates/react-app/src/api.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts similarity index 58% rename from waspc/data/Generator/templates/react-app/src/api.js rename to waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts index 98238ee21..09d848be6 100644 --- a/waspc/data/Generator/templates/react-app/src/api.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx new file mode 100644 index 000000000..da69a3b31 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx @@ -0,0 +1,260 @@ +import { useState, FormEvent, useEffect } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { createTheme } from '@stitches/react' + +import * as SocialIcons from './SocialIcons' +import { SocialButton } from './SocialButton'; + +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', +}) + +const googleSignInUrl = `${config.apiUrl}/auth/google/login` + +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); + + // 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 = (<> + + {cta} with + + + + + + + ) + + return ( + +
    + {logo && ( + Your Company + )} + {title} +
    + + {errorMessage && ( + {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description} + )} + {successMessage && {successMessage}} + {(state === 'login' || state === 'signup') && loginSignupForm} +
    + ) +} + +export default Auth; + + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/ForgotPassword.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/ForgotPassword.tsx new file mode 100644 index 000000000..dad842e7c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx deleted file mode 100644 index b29a93124..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx +++ /dev/null @@ -1,46 +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('/') - } catch (err) { - console.log(err) - window.alert(errorMessage(err)) - } - } - - return ( -
    -

    Username

    - setUsernameFieldVal(e.target.value)} - /> -

    Password

    - setPasswordFieldVal(e.target.value)} - /> -
    - -
    -
    - ) -} - -export default LoginForm diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.tsx new file mode 100644 index 000000000..7d015510b --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/ResetPassword.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/ResetPassword.tsx new file mode 100644 index 000000000..8ac4bef74 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx deleted file mode 100644 index 151f353af..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx +++ /dev/null @@ -1,52 +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('/') - } catch (err) { - console.log(err) - window.alert(errorMessage(err)) - } - } - - return ( -
    -

    Username

    - setUsernameFieldVal(e.target.value)} - /> -

    Password

    - setPasswordFieldVal(e.target.value)} - /> -
    - -
    -
    - ) -} - -export default SignupForm diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx new file mode 100644 index 000000000..275885844 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialButton.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialButton.tsx new file mode 100644 index 000000000..21de1739e --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialIcons.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialIcons.tsx new file mode 100644 index 000000000..a9d13e7dd --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/VerifyEmail.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/VerifyEmail.tsx new file mode 100644 index 000000000..ca76aeb8d --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts new file mode 100644 index 000000000..560c6930d --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx index 6bda95408..a54219e8c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx @@ -1,44 +1,14 @@ import config from '../../config.js' +import { SocialButton } from '../forms/SocialButton' +import * as SocialIcons from '../forms/SocialIcons' -export const signInUrl = `${config.apiUrl}/auth/external/google/login` -export const logoUrl = '/images/google-logo-icon.png' - -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 const signInUrl = `${config.apiUrl}/auth/google/login` export function SignInButton() { return ( - + + + ) } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts index e673cf8d7..1c6fc500f 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/login.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/login.js deleted file mode 100644 index dc0987ea2..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/login.js +++ /dev/null @@ -1,13 +0,0 @@ -import api, { handleApiError } from '../api.js' -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) - - await initSession(response.data.token) - } catch (error) { - handleApiError(error) - } -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.js index 3d8ce8414..88236169e 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx index 349d03f4a..c59e46143 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx @@ -2,7 +2,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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx index c0c24db28..621ef393d 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx @@ -1,7 +1,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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/signup.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/signup.js deleted file mode 100644 index 0a2e86933..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/signup.js +++ /dev/null @@ -1,9 +0,0 @@ -import api, { handleApiError } from '../api.js' - -export default async function signup(userFields) { - try { - await api.post('/auth/signup', userFields) - } catch (error) { - handleApiError(error) - } -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/useAuth.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/useAuth.js deleted file mode 100644 index de32713d1..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts new file mode 100644 index 000000000..14f732b5a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts @@ -0,0 +1,39 @@ +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: ['"User"'], + }) + + return getMe +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/index.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/index.js deleted file mode 100644 index e84d0373a..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/index.ts new file mode 100644 index 000000000..2d4494b05 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/resources.js index 70c0458a4..779cd55a5 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.js deleted file mode 100644 index ba33b6368..000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createQuery } from './core' - -export default createQuery( - 'operations/my-special-query', - ['User'], -) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.ts new file mode 100644 index 000000000..81cb08f77 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.ts @@ -0,0 +1,10 @@ +import { createQuery } from './core' +import { MySpecialQuery } from '../../../server/src/queries/MySpecialQuery' + + +const query = createQuery( + 'operations/my-special-query', + ['"User"'], +) + +export default query diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.d.ts index edde495db..e1bdbe478 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.js index bfe2d90c6..5103db1d8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/core.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts index 3716204db..c007ff4c9 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.jsx index 81bb71946..aff51f130 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.jsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/router.jsx @@ -13,7 +13,7 @@ const router = ( - + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js new file mode 100644 index 000000000..741c20560 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-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/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/storage.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/storage.ts new file mode 100644 index 000000000..91e039903 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/index.ts new file mode 100644 index 000000000..f002be83c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/vitest/helpers.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/vitest/helpers.tsx new file mode 100644 index 000000000..655c01a29 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/vitest/setup.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/test/vitest/setup.ts new file mode 100644 index 000000000..d263e51f4 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/types.ts new file mode 100644 index 000000000..982b766e3 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/tsconfig.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/tsconfig.json index fdeb6c94b..c5012408c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts index 4948c6fe7..8b3650d21 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/vite.config.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.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/waspComplexTest-golden/waspComplexTest/main.wasp b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp index 51920d57c..e7f553c2c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp @@ -1,7 +1,7 @@ app waspComplexTest { db: { system: PostgreSQL }, wasp: { - version: "^0.9.0" + version: "^0.10.0" }, auth: { userEntity: User, @@ -21,6 +21,14 @@ app waspComplexTest { rootComponent: import App from "@client/App.jsx" }, + emailSender: { + provider: SendGrid, + defaultFrom: { + name: "Hello", + email: "hello@itsme.com" + }, + }, + dependencies: [ ("redux", "^4.0.5"), ("react-redux", "^7.1.3") @@ -67,3 +75,14 @@ query MySpecialQuery { entities: [User], } +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 +} + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/server/apis.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/server/apis.ts new file mode 100644 index 000000000..a2fa6f428 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/server/apis.ts @@ -0,0 +1,9 @@ +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!' }) +} + diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest index 0e7328b1f..066729f41 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest @@ -1,8 +1,10 @@ +waspJob/.env.server waspJob/.wasp/out/.dockerignore waspJob/.wasp/out/.waspchecksums waspJob/.wasp/out/Dockerfile waspJob/.wasp/out/db/schema.prisma waspJob/.wasp/out/installedFullStackNpmDependencies.json +waspJob/.wasp/out/server/.env waspJob/.wasp/out/server/.npmrc waspJob/.wasp/out/server/README.md waspJob/.wasp/out/server/nodemon.json @@ -16,7 +18,8 @@ waspJob/.wasp/out/server/src/app.js waspJob/.wasp/out/server/src/config.js waspJob/.wasp/out/server/src/core/AuthError.js waspJob/.wasp/out/server/src/core/HttpError.js -waspJob/.wasp/out/server/src/dbClient.js +waspJob/.wasp/out/server/src/dbClient.ts +waspJob/.wasp/out/server/src/dbSeed/types.ts waspJob/.wasp/out/server/src/entities/index.ts waspJob/.wasp/out/server/src/ext-src/jobs/bar.js waspJob/.wasp/out/server/src/jobs/MySpecialJob.js @@ -31,9 +34,11 @@ waspJob/.wasp/out/server/src/routes/index.js waspJob/.wasp/out/server/src/routes/operations/index.js waspJob/.wasp/out/server/src/server.ts waspJob/.wasp/out/server/src/types/index.ts +waspJob/.wasp/out/server/src/universal/types.ts waspJob/.wasp/out/server/src/universal/url.ts waspJob/.wasp/out/server/src/utils.js waspJob/.wasp/out/server/tsconfig.json +waspJob/.wasp/out/web-app/.env waspJob/.wasp/out/web-app/.npmrc waspJob/.wasp/out/web-app/README.md waspJob/.wasp/out/web-app/index.html @@ -46,7 +51,7 @@ waspJob/.wasp/out/web-app/scripts/validate-env.mjs waspJob/.wasp/out/web-app/src/actions/core.d.ts waspJob/.wasp/out/web-app/src/actions/core.js waspJob/.wasp/out/web-app/src/actions/index.ts -waspJob/.wasp/out/web-app/src/api.js +waspJob/.wasp/out/web-app/src/api.ts waspJob/.wasp/out/web-app/src/config.js waspJob/.wasp/out/web-app/src/entities/index.ts waspJob/.wasp/out/web-app/src/ext-src/Main.css @@ -55,7 +60,7 @@ waspJob/.wasp/out/web-app/src/ext-src/vite-env.d.ts waspJob/.wasp/out/web-app/src/ext-src/waspLogo.png waspJob/.wasp/out/web-app/src/index.tsx waspJob/.wasp/out/web-app/src/logo.png -waspJob/.wasp/out/web-app/src/operations/index.js +waspJob/.wasp/out/web-app/src/operations/index.ts waspJob/.wasp/out/web-app/src/operations/resources.js waspJob/.wasp/out/web-app/src/operations/updateHandlersMap.js waspJob/.wasp/out/web-app/src/queries/core.d.ts @@ -64,6 +69,12 @@ waspJob/.wasp/out/web-app/src/queries/index.d.ts waspJob/.wasp/out/web-app/src/queries/index.js waspJob/.wasp/out/web-app/src/queryClient.js waspJob/.wasp/out/web-app/src/router.jsx +waspJob/.wasp/out/web-app/src/storage.ts +waspJob/.wasp/out/web-app/src/test/index.ts +waspJob/.wasp/out/web-app/src/test/vitest/helpers.tsx +waspJob/.wasp/out/web-app/src/test/vitest/setup.ts +waspJob/.wasp/out/web-app/src/types.ts +waspJob/.wasp/out/web-app/src/universal/types.ts waspJob/.wasp/out/web-app/src/universal/url.ts waspJob/.wasp/out/web-app/src/utils.js waspJob/.wasp/out/web-app/src/vite-env.d.ts diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.env.server b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.env.server new file mode 100644 index 000000000..77a558a00 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.env.server @@ -0,0 +1,2 @@ +DATABASE_URL=mock-database-url + diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 4245fe2dd..32b2b35f4 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -20,6 +20,13 @@ ], "6f7b1b109e332bad9eb3cda4a2caf4963f4918c91b546c06fa42d8986c0b94a2" ], + [ + [ + "file", + "server/.env" + ], + "b267ea48e5ff257c153b4f84102104eec7af8a46827aae6e76fc42c83934cfc0" + ], [ [ "file", @@ -53,7 +60,7 @@ "file", "server/package.json" ], - "6bc71fa70fd599eebfeeab411d439c59bc4e2ef68791b8a14a450351ac1842c6" + "adc80bda2d164f54eeab5b3f18bdf57f4e277ef18de5127ca78a742ea782c55d" ], [ [ @@ -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", @@ -221,7 +235,14 @@ "file", "server/src/types/index.ts" ], - "37352cdaeae444c90632fe788d1e49eca3927ef8755702ba95c81c8b90d9f408" + "1fd50e251e340a5bc8c51369766e8c889cf892cdbe6593b4d58a6ee585b6d2cc" + ], + [ + [ + "file", + "server/src/universal/types.ts" + ], + "8ef378c1447289104856ec1a92931fd1280c0f8d0a8463008674e93bd798b884" ], [ [ @@ -235,14 +256,21 @@ "file", "server/src/utils.js" ], - "68a5794f55e24b303d81456a1181a3a2cd70773f6ebc4e7a63dac064834aa8e9" + "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" ], [ [ "file", "server/tsconfig.json" ], - "c58318dce36bc48fdd1e998af906b5026c87b1d80415653b03a7285acdb56d69" + "f2632965c1e3678fcc0e63b83d7e33fea1a9008ef5fd5a2f5e7bf278337c3e02" + ], + [ + [ + "file", + "web-app/.env" + ], + "b267ea48e5ff257c153b4f84102104eec7af8a46827aae6e76fc42c83934cfc0" ], [ [ @@ -284,7 +312,7 @@ "file", "web-app/package.json" ], - "6f13980fb69f631e22c97fa3961f66182327564bc34669a2b86a5607df0eee21" + "a106a1312e7a5f68b07eac0842c8e274aa42aad5ed0cdddc0104fe3c27b6f0c9" ], [ [ @@ -319,28 +347,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" ], [ [ @@ -401,16 +429,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" ], [ [ @@ -424,21 +452,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" ], [ [ @@ -461,6 +489,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", @@ -487,7 +557,7 @@ "file", "web-app/tsconfig.json" ], - "27e39dd3e6155ffccdb1d9cb0cba8db7d9e06e10958bee900340a2d9f17400c8" + "887c55937264ea8b2c538340962c3011091cf3eb6b9d39523acbe8ebcdd35474" ], [ [ @@ -501,6 +571,6 @@ "file", "web-app/vite.config.ts" ], - "ab5c138ab0ab01f5108c2da9a8bf6b673b79115ca97276f6293da8e3bc0a45b1" + "0ab8b3892a5d5d25b85646ef30e8b2487904415021912e68670fab316b2ecf2d" ] ] \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json index 772d25d49..72b05df41 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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":"pg-boss","version":"^8.0.0"}],"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"},{"name":"pg-boss","version":"^8.4.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/waspJob-golden/waspJob/.wasp/out/server/.env b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/.env new file mode 100644 index 000000000..f06ce8101 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/.env @@ -0,0 +1 @@ +DATABASE_URL="mock-database-url" \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json index 7d7bfaaa3..ee6801188 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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,16 +10,19 @@ "lodash.merge": "^4.6.2", "morgan": "~1.10.0", "patch-package": "^6.4.7", - "pg-boss": "^8.0.0", + "pg-boss": "^8.4.2", + "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" }, @@ -36,12 +39,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/waspJob-golden/waspJob/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/_types/index.ts index 27ec89ccc..95efc986b 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/_types/index.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js index 7ffcd317d..7d87c21e4 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js index c23cf0959..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts similarity index 100% rename from waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.js rename to waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbSeed/types.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbSeed/types.ts new file mode 100644 index 000000000..fe0d5396a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts index 994904589..bef664aac 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.js index e07b121be..d6c7ed639 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/server/tsconfig.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/tsconfig.json index 562d7839c..8999362fe 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/.env b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/.env new file mode 100644 index 000000000..f06ce8101 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/.env @@ -0,0 +1 @@ +DATABASE_URL="mock-database-url" \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json index 37ecc76b7..875db8d8b 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.d.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.d.ts index 8a38c25e3..fa31f329f 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.js index 3258f0d85..440e90614 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/core.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts index 292f8849b..5e4dfedd1 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/api.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.js deleted file mode 100644 index 98238ee21..000000000 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.js +++ /dev/null @@ -1,74 +0,0 @@ -import axios from 'axios' -import config from './config' - -const api = axios.create({ - baseURL: config.apiUrl, -}) - -const WASP_APP_AUTH_TOKEN_NAME = "authToken" - -let authToken = null -if (window.localStorage) { - authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME) -} - -export const setAuthToken = (token) => { - if (typeof token !== 'string') { - throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`) - } - authToken = token - window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token) -} - -export const clearAuthToken = () => { - authToken = undefined - window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME) -} - -export const clearLocalStorage = () => { - authToken = undefined - - window.localStorage && window.localStorage.clear() -} - -api.interceptors.request.use(request => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` - } - return request -}) - -api.interceptors.response.use(undefined, error => { - if (error.response?.status === 401) { - clearAuthToken() - } - return Promise.reject(error) -}) - -/** - * Takes an error returned by the app's API (as returned by axios), and transforms into a more - * 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) => { - if (error?.response) { - // If error came from HTTP response, we capture most informative message - // and also add .statusCode information to it. - // If error had JSON response, we assume it is of format { message, data } and - // add that info to the error. - // TODO: We might want to use HttpError here instead of just Error, since - // HttpError is also used on server to throw errors like these. - // 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 - } else { - // If any other error, we just propagate it. - throw error - } -} - -export default api diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts new file mode 100644 index 000000000..09d848be6 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts @@ -0,0 +1,78 @@ +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' + +let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined + +export function setAuthToken (token: string): void { + authToken = token + storage.set(WASP_APP_AUTH_TOKEN_NAME, token) +} + +export function clearAuthToken(): void { + authToken = undefined + storage.remove(WASP_APP_AUTH_TOKEN_NAME) +} + +export function removeLocalUserData(): void { + authToken = undefined + + storage.clear() +} + +api.interceptors.request.use((request) => { + if (authToken) { + request.headers['Authorization'] = `Bearer ${authToken}` + } + return request +}) + +api.interceptors.response.use(undefined, (error) => { + if (error.response?.status === 401) { + clearAuthToken() + } + return Promise.reject(error) +}) + +/** + * Takes an error returned by the app's API (as returned by axios), and transforms into a more + * 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 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. + // If error had JSON response, we assume it is of format { message, data } and + // add that info to the error. + // TODO: We might want to use HttpError here instead of just Error, since + // HttpError is also used on server to throw errors like these. + // That would require copying HttpError code to web-app also and using it here. + const responseJson = error.response?.data + const responseStatusCode = error.response.status + 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/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/index.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/index.js deleted file mode 100644 index e84d0373a..000000000 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/index.ts new file mode 100644 index 000000000..2d4494b05 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js index 70c0458a4..779cd55a5 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.d.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.d.ts index edde495db..e1bdbe478 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.js index bfe2d90c6..5103db1d8 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/core.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/index.d.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/index.d.ts index 3716204db..c007ff4c9 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/queries/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/storage.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/storage.ts new file mode 100644 index 000000000..91e039903 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/test/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/test/index.ts new file mode 100644 index 000000000..f002be83c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/test/vitest/helpers.tsx b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/test/vitest/helpers.tsx new file mode 100644 index 000000000..655c01a29 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/test/vitest/setup.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/test/vitest/setup.ts new file mode 100644 index 000000000..d263e51f4 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/types.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/types.ts new file mode 100644 index 000000000..982b766e3 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/tsconfig.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/tsconfig.json index fdeb6c94b..c5012408c 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/.wasp/out/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/vite.config.ts index 4948c6fe7..8b3650d21 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/vite.config.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.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/waspJob-golden/waspJob/main.wasp b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp index 6ff532df2..30968f1d9 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp @@ -1,7 +1,7 @@ app waspJob { db: { system: PostgreSQL }, wasp: { - version: "^0.9.0" + version: "^0.10.0" }, title: "waspJob" } diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest index 9267c925a..92d5195f7 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest @@ -8,6 +8,7 @@ waspMigrate/.wasp/out/db/schema.prisma waspMigrate/.wasp/out/db/schema.prisma.wasp-generate-checksum waspMigrate/.wasp/out/db/schema.prisma.wasp-last-db-concurrence-checksum waspMigrate/.wasp/out/installedFullStackNpmDependencies.json +waspMigrate/.wasp/out/server/.env waspMigrate/.wasp/out/server/.npmrc waspMigrate/.wasp/out/server/README.md waspMigrate/.wasp/out/server/nodemon.json @@ -21,7 +22,8 @@ waspMigrate/.wasp/out/server/src/app.js waspMigrate/.wasp/out/server/src/config.js waspMigrate/.wasp/out/server/src/core/AuthError.js waspMigrate/.wasp/out/server/src/core/HttpError.js -waspMigrate/.wasp/out/server/src/dbClient.js +waspMigrate/.wasp/out/server/src/dbClient.ts +waspMigrate/.wasp/out/server/src/dbSeed/types.ts waspMigrate/.wasp/out/server/src/entities/index.ts waspMigrate/.wasp/out/server/src/jobs/core/Job.js waspMigrate/.wasp/out/server/src/jobs/core/SubmittedJob.js @@ -34,9 +36,11 @@ waspMigrate/.wasp/out/server/src/routes/index.js waspMigrate/.wasp/out/server/src/routes/operations/index.js waspMigrate/.wasp/out/server/src/server.ts waspMigrate/.wasp/out/server/src/types/index.ts +waspMigrate/.wasp/out/server/src/universal/types.ts waspMigrate/.wasp/out/server/src/universal/url.ts waspMigrate/.wasp/out/server/src/utils.js waspMigrate/.wasp/out/server/tsconfig.json +waspMigrate/.wasp/out/web-app/.env waspMigrate/.wasp/out/web-app/.npmrc waspMigrate/.wasp/out/web-app/README.md waspMigrate/.wasp/out/web-app/index.html @@ -49,7 +53,7 @@ waspMigrate/.wasp/out/web-app/scripts/validate-env.mjs waspMigrate/.wasp/out/web-app/src/actions/core.d.ts waspMigrate/.wasp/out/web-app/src/actions/core.js waspMigrate/.wasp/out/web-app/src/actions/index.ts -waspMigrate/.wasp/out/web-app/src/api.js +waspMigrate/.wasp/out/web-app/src/api.ts waspMigrate/.wasp/out/web-app/src/config.js waspMigrate/.wasp/out/web-app/src/entities/index.ts waspMigrate/.wasp/out/web-app/src/ext-src/Main.css @@ -58,7 +62,7 @@ waspMigrate/.wasp/out/web-app/src/ext-src/vite-env.d.ts waspMigrate/.wasp/out/web-app/src/ext-src/waspLogo.png waspMigrate/.wasp/out/web-app/src/index.tsx waspMigrate/.wasp/out/web-app/src/logo.png -waspMigrate/.wasp/out/web-app/src/operations/index.js +waspMigrate/.wasp/out/web-app/src/operations/index.ts waspMigrate/.wasp/out/web-app/src/operations/resources.js waspMigrate/.wasp/out/web-app/src/operations/updateHandlersMap.js waspMigrate/.wasp/out/web-app/src/queries/core.d.ts @@ -67,6 +71,12 @@ waspMigrate/.wasp/out/web-app/src/queries/index.d.ts waspMigrate/.wasp/out/web-app/src/queries/index.js waspMigrate/.wasp/out/web-app/src/queryClient.js waspMigrate/.wasp/out/web-app/src/router.jsx +waspMigrate/.wasp/out/web-app/src/storage.ts +waspMigrate/.wasp/out/web-app/src/test/index.ts +waspMigrate/.wasp/out/web-app/src/test/vitest/helpers.tsx +waspMigrate/.wasp/out/web-app/src/test/vitest/setup.ts +waspMigrate/.wasp/out/web-app/src/types.ts +waspMigrate/.wasp/out/web-app/src/universal/types.ts waspMigrate/.wasp/out/web-app/src/universal/url.ts waspMigrate/.wasp/out/web-app/src/utils.js waspMigrate/.wasp/out/web-app/src/vite-env.d.ts diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index 86ae1f2f2..cdf67672d 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -20,6 +20,13 @@ ], "8d017edd849a861ae086850270a9f817bb4b75d9ee9ac27c08b0e9c29a16f6fe" ], + [ + [ + "file", + "server/.env" + ], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ], [ [ "file", @@ -53,7 +60,7 @@ "file", "server/package.json" ], - "478ff50cf793c66fbf195bbdfcb6fe46f68f3623fd854fb346b312444cbe8ad7" + "869cb68457c7061bdf0cfa70e09bc8a599e2cb00d395b6b23c422cf7d092a845" ], [ [ @@ -74,7 +81,7 @@ "file", "server/src/_types/index.ts" ], - "b8f7d0da80ee641e234614b021665d4bbaef551c17481af3aa670df757ff8914" + "30830d67c50d4d3f00613e9f106d824c4243a7fd649d3c6449781e10616b09e3" ], [ [ @@ -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" ], - "54b7e66b5dc8c14c01fd424b118883370b24c4dba232120bfc808714d54baa11" + "e3d7fd9178112869b0e73b9dbf4af45f791805e256d03aa7a8505c124af726a8" ], [ [ @@ -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/waspMigrate-golden/waspMigrate/.wasp/out/db/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/db/package.json index 6ad25b078..9d457f080 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/db/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json index 21bf1e41b..d51f20798 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/.env b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/.env new file mode 100644 index 000000000..e69de29bb diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json index 74d0c01c2..86d264385 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/_types/index.ts index 86755a877..5826033c8 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/_types/index.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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] @@ -24,9 +36,7 @@ type PrismaDelegate = { "Task": typeof prisma.task, } -// 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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js index 7ffcd317d..7d87c21e4 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js index c23cf0959..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts similarity index 100% rename from waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.js rename to waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbSeed/types.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbSeed/types.ts new file mode 100644 index 000000000..fe0d5396a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts index 994904589..bef664aac 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js index e07b121be..d6c7ed639 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/server/tsconfig.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/tsconfig.json index 562d7839c..8999362fe 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/.env b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/.env new file mode 100644 index 000000000..e69de29bb diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json index ba64ce3a9..27ec0bab6 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.d.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.d.ts index 8a38c25e3..fa31f329f 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.js index 3258f0d85..440e90614 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/core.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts index 292f8849b..5e4dfedd1 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.js deleted file mode 100644 index 98238ee21..000000000 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.js +++ /dev/null @@ -1,74 +0,0 @@ -import axios from 'axios' -import config from './config' - -const api = axios.create({ - baseURL: config.apiUrl, -}) - -const WASP_APP_AUTH_TOKEN_NAME = "authToken" - -let authToken = null -if (window.localStorage) { - authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME) -} - -export const setAuthToken = (token) => { - if (typeof token !== 'string') { - throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`) - } - authToken = token - window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token) -} - -export const clearAuthToken = () => { - authToken = undefined - window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME) -} - -export const clearLocalStorage = () => { - authToken = undefined - - window.localStorage && window.localStorage.clear() -} - -api.interceptors.request.use(request => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` - } - return request -}) - -api.interceptors.response.use(undefined, error => { - if (error.response?.status === 401) { - clearAuthToken() - } - return Promise.reject(error) -}) - -/** - * Takes an error returned by the app's API (as returned by axios), and transforms into a more - * 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) => { - if (error?.response) { - // If error came from HTTP response, we capture most informative message - // and also add .statusCode information to it. - // If error had JSON response, we assume it is of format { message, data } and - // add that info to the error. - // TODO: We might want to use HttpError here instead of just Error, since - // HttpError is also used on server to throw errors like these. - // 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 - } else { - // If any other error, we just propagate it. - throw error - } -} - -export default api diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts new file mode 100644 index 000000000..09d848be6 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts @@ -0,0 +1,78 @@ +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' + +let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined + +export function setAuthToken (token: string): void { + authToken = token + storage.set(WASP_APP_AUTH_TOKEN_NAME, token) +} + +export function clearAuthToken(): void { + authToken = undefined + storage.remove(WASP_APP_AUTH_TOKEN_NAME) +} + +export function removeLocalUserData(): void { + authToken = undefined + + storage.clear() +} + +api.interceptors.request.use((request) => { + if (authToken) { + request.headers['Authorization'] = `Bearer ${authToken}` + } + return request +}) + +api.interceptors.response.use(undefined, (error) => { + if (error.response?.status === 401) { + clearAuthToken() + } + return Promise.reject(error) +}) + +/** + * Takes an error returned by the app's API (as returned by axios), and transforms into a more + * 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 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. + // If error had JSON response, we assume it is of format { message, data } and + // add that info to the error. + // TODO: We might want to use HttpError here instead of just Error, since + // HttpError is also used on server to throw errors like these. + // That would require copying HttpError code to web-app also and using it here. + const responseJson = error.response?.data + const responseStatusCode = error.response.status + 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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/index.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/index.js deleted file mode 100644 index e84d0373a..000000000 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/index.ts new file mode 100644 index 000000000..2d4494b05 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js index 70c0458a4..779cd55a5 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.d.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.d.ts index edde495db..e1bdbe478 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.d.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.js index bfe2d90c6..5103db1d8 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/core.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/index.d.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/index.d.ts index 3716204db..c007ff4c9 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/queries/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/storage.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/storage.ts new file mode 100644 index 000000000..91e039903 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/index.ts new file mode 100644 index 000000000..f002be83c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/vitest/helpers.tsx b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/vitest/helpers.tsx new file mode 100644 index 000000000..655c01a29 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/vitest/setup.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/test/vitest/setup.ts new file mode 100644 index 000000000..d263e51f4 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/types.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/types.ts new file mode 100644 index 000000000..982b766e3 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/universal/types.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/universal/types.ts new file mode 100644 index 000000000..8cadbd740 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/tsconfig.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/tsconfig.json index fdeb6c94b..c5012408c 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/tsconfig.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/.wasp/out/web-app/vite.config.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/vite.config.ts index 4948c6fe7..8b3650d21 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/vite.config.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.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/waspMigrate-golden/waspMigrate/main.wasp b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp index 4840350f6..60db53d72 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp @@ -1,6 +1,6 @@ app waspMigrate { wasp: { - version: "^0.9.0" + version: "^0.10.0" }, title: "waspMigrate" } diff --git a/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp b/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp index 14bbfbf6b..c4e01c058 100644 --- a/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp +++ b/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp @@ -1,6 +1,6 @@ app waspNew { wasp: { - version: "^0.9.0" + version: "^0.10.0" }, title: "waspNew" } diff --git a/waspc/examples/todoApp/migrations/20220413175924_switch_to_postgresql/migration.sql b/waspc/examples/todoApp/migrations/20220413175924_switch_to_postgresql/migration.sql deleted file mode 100644 index a966dd4e1..000000000 --- a/waspc/examples/todoApp/migrations/20220413175924_switch_to_postgresql/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Task" ( - "id" SERIAL NOT NULL, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER NOT NULL, - - CONSTRAINT "Task_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/migrations/20220818151104_rename_email_to_username/migration.sql b/waspc/examples/todoApp/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index 10182cf68..000000000 --- a/waspc/examples/todoApp/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User_email_key"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql b/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql deleted file mode 100644 index 0ae1754d7..000000000 --- a/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CreateTable -CREATE TABLE "SocialLogin" ( - "id" SERIAL NOT NULL, - "provider" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId"); - --- AddForeignKey -ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/migrations/20230404165422_initial/migration.sql b/waspc/examples/todoApp/migrations/20230404165422_initial/migration.sql new file mode 100644 index 000000000..eadcb049c --- /dev/null +++ b/waspc/examples/todoApp/migrations/20230404165422_initial/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT, + "password" TEXT, + "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, + "emailVerificationSentAt" TIMESTAMP(3), + "passwordResetSentAt" TIMESTAMP(3), + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SocialLogin" ( + "id" SERIAL NOT NULL, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" SERIAL NOT NULL, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId"); + +-- AddForeignKey +ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/src/client/App.jsx b/waspc/examples/todoApp/src/client/App.tsx similarity index 61% rename from waspc/examples/todoApp/src/client/App.jsx rename to waspc/examples/todoApp/src/client/App.tsx index 3fd7c7d63..082f221e7 100644 --- a/waspc/examples/todoApp/src/client/App.jsx +++ b/waspc/examples/todoApp/src/client/App.tsx @@ -1,11 +1,15 @@ import { Link } from 'react-router-dom' import logout from '@wasp/auth/logout.js' -import useAuth from '@wasp/auth/useAuth.js' +import useAuth from '@wasp/auth/useAuth' +import { useQuery } from '@wasp/queries' +import getDate from '@wasp/queries/getDate' import './Main.css' -export function App({ children }) { + +export function App({ children }: any) { const { data: user } = useAuth() + const { data: date } = useQuery(getDate) return (
    @@ -13,10 +17,13 @@ export function App({ children }) {

    ToDo App

    +

    + Your site was loaded at: {date?.toLocaleString()} +

    {user && (
    - Hello, {user.username} + Hello, {user.email}
    ) } diff --git a/waspc/examples/todoApp/src/client/Todo.test.tsx b/waspc/examples/todoApp/src/client/Todo.test.tsx new file mode 100644 index 000000000..eb404a953 --- /dev/null +++ b/waspc/examples/todoApp/src/client/Todo.test.tsx @@ -0,0 +1,56 @@ +import { test, expect } from 'vitest' +import { screen } from '@testing-library/react' + +import { mockServer, renderInContext } from '@wasp/test' +import getTasks from '@wasp/queries/getTasks' +import getDate from '@wasp/queries/getDate' +import Todo, { areThereAnyTasks } from './Todo' +import { App } from './App' +import { getMe } from '@wasp/auth/useAuth' + +test('areThereAnyTasks', () => { + expect(areThereAnyTasks([])).toBe(false) +}) + +const { mockQuery } = mockServer() + +const mockTasks = [{ + id: 1, + description: 'test todo 1', + isDone: true, + userId: 1 +}] + +test('handles mock data', async () => { + mockQuery(getTasks, mockTasks) + + renderInContext() + + await screen.findByText('test todo 1') + + expect(screen.getByRole('checkbox')).toBeChecked() + + screen.debug() +}) + +const mockUser = { + id: 12, + email: 'elon@tesla.com', + isEmailVerified: false, + emailVerificationSentAt: null, + passwordResetSentAt: null +} + +test('handles multiple mock data sources', async () => { + mockQuery(getMe, mockUser) + mockQuery(getDate, new Date()) + mockQuery(getTasks, mockTasks) + + renderInContext() + + await screen.findByText('elon@tesla.com') + + expect(screen.getByRole('checkbox')).toBeChecked() + + screen.debug() +}) diff --git a/waspc/examples/todoApp/src/client/Todo.tsx b/waspc/examples/todoApp/src/client/Todo.tsx index d0da688ec..d2a8b07ab 100644 --- a/waspc/examples/todoApp/src/client/Todo.tsx +++ b/waspc/examples/todoApp/src/client/Todo.tsx @@ -10,22 +10,16 @@ import deleteCompletedTasks from '@wasp/actions/deleteCompletedTasks.js' import toggleAllTasks from '@wasp/actions/toggleAllTasks.js' import { Task } from '@wasp/entities' -type GetTasksError = { message: string } - type NonEmptyArray = [T, ...T[]] -function areThereAnyTasks( +export function areThereAnyTasks( tasks: Task[] | undefined ): tasks is NonEmptyArray { return !!(tasks && tasks.length > 0) } const Todo = () => { - const { - data: tasks, - isError, - error: tasksError, - } = useQuery<{}, Task[], GetTasksError>(getTasks) + const { data: tasks, isError, error: tasksError } = useQuery(getTasks) const TasksError = () => { return ( @@ -153,10 +147,28 @@ const TaskView = ({ task }: { task: Task }) => { const NewTaskForm = () => { const defaultDescription = '' const [description, setDescription] = useState(defaultDescription) + const createTaskFn = useAction(createTask, { + optimisticUpdates: [ + { + getQuerySpecifier: () => [getTasks], + updateQuery: (newTask, oldTasks) => { + if (oldTasks === undefined) { + // cache is empty + return [newTask as Task] + } else { + return [...oldTasks, newTask as Task] + } + }, + } as OptimisticUpdateDefinition< + Pick, + Task[] + >, + ], + }) const createNewTask = async (description: Task['description']) => { const task = { isDone: false, description } - await createTask(task) + await createTaskFn(task) } const handleNewTaskSubmit: FormEventHandler = async ( @@ -164,8 +176,8 @@ const NewTaskForm = () => { ) => { event.preventDefault() try { - await createNewTask(description) setDescription(defaultDescription) + await createNewTask(description) } catch (err) { console.log(err) } diff --git a/waspc/examples/todoApp/src/client/pages/ProfilePage.tsx b/waspc/examples/todoApp/src/client/pages/ProfilePage.tsx index 522f4719d..8d5f2b089 100644 --- a/waspc/examples/todoApp/src/client/pages/ProfilePage.tsx +++ b/waspc/examples/todoApp/src/client/pages/ProfilePage.tsx @@ -1,13 +1,28 @@ -import React from 'react' +import React, { useEffect } from 'react' import { Link } from 'react-router-dom' import { User } from '@wasp/entities' +import api from '@wasp/api' + +async function fetchCustomRoute() { + const res = await api.get('/foo/bar') + console.log(res.data) +} + +export const ProfilePage = ({ + user: { email, isEmailVerified }, +}: { + user: User +}) => { + useEffect(() => { + fetchCustomRoute() + }, []) -export const ProfilePage = ({ user: { username } }: { user: User }) => { return ( <>

    Profile page

    - Hello {username}! + Hello {email}! Your status is{' '} + {isEmailVerified ? 'verfied' : 'unverified'}.

    Go to dashboard diff --git a/waspc/examples/todoApp/src/client/pages/Task.tsx b/waspc/examples/todoApp/src/client/pages/Task.tsx index d704f6d99..d191e47d4 100644 --- a/waspc/examples/todoApp/src/client/pages/Task.tsx +++ b/waspc/examples/todoApp/src/client/pages/Task.tsx @@ -13,9 +13,9 @@ type TaskPayload = Pick const Todo = (props: any) => { const taskId = parseInt(props.match.params.id) - const { data: task, isFetching, error } = useQuery, Task>(getTask, { id: taskId }) + const { data: task, isFetching, error } = useQuery(getTask, { id: taskId }) - const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { + const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { optimisticUpdates: [ { getQuerySpecifier: () => [getTask, { id: taskId }], diff --git a/waspc/examples/todoApp/src/client/pages/auth/EmailVerification.tsx b/waspc/examples/todoApp/src/client/pages/auth/EmailVerification.tsx new file mode 100644 index 000000000..993c7fa4f --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/EmailVerification.tsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom' + +import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail' +import appearance from './appearance' +import todoLogo from '../../todoLogo.png' + +export function EmailVerification() { + return ( +
    +
    +
    +
    + +
    + + If everything is okay, go to login + +
    +
    +
    +
    + ) +} diff --git a/waspc/examples/todoApp/src/client/pages/auth/Login.jsx b/waspc/examples/todoApp/src/client/pages/auth/Login.jsx deleted file mode 100644 index d79a76c63..000000000 --- a/waspc/examples/todoApp/src/client/pages/auth/Login.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import { Link } from 'react-router-dom' - -import LoginForm from '@wasp/auth/forms/Login' -// import { SignInButton as GoogleSignInButton } from '@wasp/auth/helpers/Google' -// import { SignInButton as GitHubSignInButton } from '@wasp/auth/helpers/GitHub' - -const Login = () => { - return ( -
    - - - I don't have an account yet (go to signup). - - {/*
    - - -
    */} -
    - ) -} - -export default Login diff --git a/waspc/examples/todoApp/src/client/pages/auth/Login.tsx b/waspc/examples/todoApp/src/client/pages/auth/Login.tsx new file mode 100644 index 000000000..f35b88a3c --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/Login.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' + +import { LoginForm } from '@wasp/auth/forms/Login' + +import appearance from './appearance' +import todoLogo from '../../todoLogo.png' + +const Login = () => { + return ( +
    +
    +
    +
    + +
    + + Don't have an account yet? go to signup. + +
    + + Forgot your password?{' '} + reset it. + +
    +
    +
    +
    + ) +} + +export default Login diff --git a/waspc/examples/todoApp/src/client/pages/auth/PasswordReset.tsx b/waspc/examples/todoApp/src/client/pages/auth/PasswordReset.tsx new file mode 100644 index 000000000..d6d0dddf1 --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/PasswordReset.tsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom' + +import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword' +import appearance from './appearance' +import todoLogo from '../../todoLogo.png' + +export function PasswordReset() { + return ( +
    +
    +
    +
    + +
    + + If everything is okay, go to login + +
    +
    +
    +
    + ) +} diff --git a/waspc/examples/todoApp/src/client/pages/auth/RequestPasswordReset.tsx b/waspc/examples/todoApp/src/client/pages/auth/RequestPasswordReset.tsx new file mode 100644 index 000000000..d1de52b11 --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/RequestPasswordReset.tsx @@ -0,0 +1,21 @@ +import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword' +import appearance from './appearance' +import todoLogo from '../../todoLogo.png' + +export function RequestPasswordReset() { + return ( +
    +
    +
    +
    + +
    +
    +
    +
    + ) +} diff --git a/waspc/examples/todoApp/src/client/pages/auth/Signup.jsx b/waspc/examples/todoApp/src/client/pages/auth/Signup.jsx deleted file mode 100644 index 4b6b9fd80..000000000 --- a/waspc/examples/todoApp/src/client/pages/auth/Signup.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react' -import { Link } from 'react-router-dom' - -import SignupForm from '@wasp/auth/forms/Signup' -import getNumTasks from '@wasp/queries/getNumTasks' -import { useQuery } from '@wasp/queries' - - -const Signup = () => { - const { data: numTasks } = useQuery(getNumTasks) - return ( - <> - -
    - - I already have an account (go to login). - -
    - - Number of tasks already created: {numTasks} - - - ) -} - -export default Signup diff --git a/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx b/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx new file mode 100644 index 000000000..175db5b3e --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom' + +import { SignupForm } from '@wasp/auth/forms/Signup' +import getNumTasks from '@wasp/queries/getNumTasks' +import { useQuery } from '@wasp/queries' +import { getTotalTaskCountMessage } from './helpers' + +import appearance from './appearance' +import todoLogo from '../../todoLogo.png' + +const Signup = () => { + const { data: numTasks } = useQuery(getNumTasks) + + return ( +
    +
    +
    +
    + +
    + + I already have an account (go to login). + +
    +
    +
    +
    + {getTotalTaskCountMessage(numTasks)} +
    +
    +
    + ) +} + +export default Signup diff --git a/waspc/examples/todoApp/src/client/pages/auth/appearance.js b/waspc/examples/todoApp/src/client/pages/auth/appearance.js new file mode 100644 index 000000000..8498d3187 --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/appearance.js @@ -0,0 +1,9 @@ +const appearance = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} + +export default appearance diff --git a/waspc/examples/todoApp/src/client/pages/auth/helpers.test.ts b/waspc/examples/todoApp/src/client/pages/auth/helpers.test.ts new file mode 100644 index 000000000..8f73f8a1b --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/helpers.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' + +import { getTotalTaskCountMessage } from './helpers' + +describe('helpers', () => { + it('not loaded yet -> empty string', () => { + expect(getTotalTaskCountMessage()).toBe('') + }) + it('no tasks -> 0 tasks message', () => { + expect(getTotalTaskCountMessage(0)).toBe('No tasks created, yet.') + }) + it('one task -> 1 task message', () => { + expect(getTotalTaskCountMessage(1)).toBe('There is just one task.') + }) + it('multiple tasks -> default message', () => { + expect(getTotalTaskCountMessage(2)).toBe( + 'There are 2 tasks created so far.' + ) + }) +}) diff --git a/waspc/examples/todoApp/src/client/pages/auth/helpers.ts b/waspc/examples/todoApp/src/client/pages/auth/helpers.ts new file mode 100644 index 000000000..ee4cc5b65 --- /dev/null +++ b/waspc/examples/todoApp/src/client/pages/auth/helpers.ts @@ -0,0 +1,12 @@ +export function getTotalTaskCountMessage(numTasks?: number): string { + if (numTasks === undefined) { + return '' + } + if (numTasks === 0) { + return 'No tasks created, yet.' + } + if (numTasks === 1) { + return 'There is just one task.' + } + return `There are ${numTasks} tasks created so far.` +} diff --git a/waspc/examples/todoApp/src/client/todoLogo.png b/waspc/examples/todoApp/src/client/todoLogo.png new file mode 100644 index 000000000..809bd97a8 Binary files /dev/null and b/waspc/examples/todoApp/src/client/todoLogo.png differ diff --git a/waspc/examples/todoApp/src/server/actions.ts b/waspc/examples/todoApp/src/server/actions.ts index c1bf68342..c6520c909 100644 --- a/waspc/examples/todoApp/src/server/actions.ts +++ b/waspc/examples/todoApp/src/server/actions.ts @@ -5,29 +5,37 @@ import { CreateTask, DeleteCompletedTasks, ToggleAllTasks, - UpdateTaskIsDone + UpdateTaskIsDone, } from '@wasp/actions/types' -export const createTask: CreateTask> = async (task, context) => { +export const createTask: CreateTask> = async ( + task, + context +) => { if (!context.user) { throw new HttpError(401) } const Task = context.entities.Task - console.log('New task created! Btw, current value of someResource is: ' + getSomeResource()) + console.log( + 'New task created! Btw, current value of someResource is: ' + + getSomeResource() + ) return Task.create({ data: { description: task.description, user: { - connect: { id: context.user.id } - } - } + connect: { id: context.user.id }, + }, + }, }) } -export const updateTaskIsDone: UpdateTaskIsDone> = async ({ id, isDone }, context) => { +export const updateTaskIsDone: UpdateTaskIsDone< + Pick +> = async ({ id, isDone }, context) => { if (!context.user) { throw new HttpError(401) } @@ -37,20 +45,24 @@ export const updateTaskIsDone: UpdateTaskIsDone> = a // await sleep(3000); const Task = context.entities.Task - return Task.updateMany({ + const updateResult = await Task.updateMany({ where: { id, user: { id: context.user.id } }, - data: { isDone } + data: { isDone }, }) + return updateResult } -export const deleteCompletedTasks: DeleteCompletedTasks = async (_args, context) => { +export const deleteCompletedTasks: DeleteCompletedTasks = async ( + _args, + context +) => { if (!context.user) { throw new HttpError(401) } const Task = context.entities.Task await Task.deleteMany({ - where: { isDone: true, user: { id: context.user.id } } + where: { isDone: true, user: { id: context.user.id } }, }) } @@ -62,8 +74,8 @@ export const toggleAllTasks: ToggleAllTasks = async (_args, context) => { const whereIsDone = (isDone: boolean) => ({ isDone, user: { id: context.user.id }, - }); - const Task = context.entities.Task; + }) + const Task = context.entities.Task const notDoneTasksCount = await Task.count({ where: whereIsDone(false) }) if (notDoneTasksCount > 0) { diff --git a/waspc/examples/todoApp/src/server/apis.ts b/waspc/examples/todoApp/src/server/apis.ts new file mode 100644 index 000000000..70392bc85 --- /dev/null +++ b/waspc/examples/todoApp/src/server/apis.ts @@ -0,0 +1,11 @@ +import { BarBaz, 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.email}!` }) +} + +export const barBaz: BarBaz = (_req, res, _context) => { + res.set('Access-Control-Allow-Origin', '*') + res.json({ msg: `Hello, stranger!` }) +} diff --git a/waspc/examples/todoApp/src/server/auth/email.ts b/waspc/examples/todoApp/src/server/auth/email.ts new file mode 100644 index 000000000..6af04c1ba --- /dev/null +++ b/waspc/examples/todoApp/src/server/auth/email.ts @@ -0,0 +1,26 @@ +import { + GetPasswordResetEmailContentFn, + GetVerificationEmailContentFn, +} from '@wasp/types' + +export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ + passwordResetLink, +}) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, +}) + +export const getVerificationEmailContent: 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 + `, +}) diff --git a/waspc/examples/todoApp/src/server/auth/google.js b/waspc/examples/todoApp/src/server/auth/google.js index 5af11e548..fb9056137 100644 --- a/waspc/examples/todoApp/src/server/auth/google.js +++ b/waspc/examples/todoApp/src/server/auth/google.js @@ -1,16 +1,15 @@ -import { generateAvailableUsername } from '@wasp/core/auth.js' - export function config() { - console.log("Inside user-supplied Google config") + console.log('Inside user-supplied Google config') return { clientID: process.env['GOOGLE_CLIENT_ID'], clientSecret: process.env['GOOGLE_CLIENT_SECRET'], - scope: ['profile'] + scope: ['profile', 'email'], } } export async function getUserFields(_context, args) { - console.log("Inside user-supplied Google getUserFields") - const username = await generateAvailableUsername(args.profile.displayName.split(' '), { separator: '.' }) - return { username } + console.log('Inside user-supplied Google getUserFields') + const email = + args.profile.emails.length > 0 ? args.profile.emails[0].value : null + return { email, isEmailVerified: !!email } } diff --git a/waspc/examples/todoApp/src/server/dbSeeds.ts b/waspc/examples/todoApp/src/server/dbSeeds.ts new file mode 100644 index 000000000..2038c4485 --- /dev/null +++ b/waspc/examples/todoApp/src/server/dbSeeds.ts @@ -0,0 +1,35 @@ +import { createTask } from './actions.js' +import type { DbSeedFn } from '@wasp/dbSeed/types.js' + +async function createUser (prismaClient, data) { + const { password, ...newUser } = await prismaClient.user.create({ data }) + return newUser +} + +export const devSeedSimple: DbSeedFn = async (prismaClient) => { + const user = await createUser(prismaClient, { + username: "martinsos", + password: "test1234" + }) + + await createTask( + { description: "My initial task" }, + { user, entities: { Task: prismaClient.task } } + ) + + console.log("Did simple dev seed!") +} + +export const prodSeed: DbSeedFn = async (prismaClient) => { + const user = await createUser(prismaClient, { + username: "martinsosProd", + password: "test1234prod" + }) + + await createTask( + { description: "My initial task in production" }, + { user, entities: { Task: prismaClient.task } } + ) + + console.log("Did seeding intended for production!") +} diff --git a/waspc/examples/todoApp/src/server/queries.ts b/waspc/examples/todoApp/src/server/queries.ts index 4347c42a0..936efa219 100644 --- a/waspc/examples/todoApp/src/server/queries.ts +++ b/waspc/examples/todoApp/src/server/queries.ts @@ -3,7 +3,8 @@ import { Task } from '@wasp/entities' import { GetNumTasks, GetTask, - GetTasks + GetTasks, + GetDate, } from '@wasp/queries/types' export const getTasks: GetTasks = async (_args, context) => { @@ -47,3 +48,7 @@ export const getTask: GetTask, Task> = async (where, context) = return task } + +export const getDate: GetDate = async () => { + return new Date() +} diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index 63b5c6e9d..d9864f1c5 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -1,6 +1,6 @@ app todoApp { wasp: { - version: "^0.8.0" + version: "^0.10.0" }, title: "ToDo App", // head: [], @@ -12,15 +12,29 @@ app todoApp { userEntity: User, // externalAuthEntity: SocialLogin, methods: { - usernameAndPassword: {}, + // usernameAndPassword: {}, // google: { // configFn: import { config } from "@server/auth/google.js", // getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" // }, // gitHub: { - // configFn: import { config } from "@server/auth/github.js", - // getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" - // } + // // configFn: import { config } from "@server/auth/github.js", + // // getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // }, + email: { + fromField: { + name: "ToDO App", + email: "mihovil@ilakovac.com" + }, + emailVerification: { + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + clientRoute: EmailVerificationRoute, + }, + passwordReset: { + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", + clientRoute: PasswordResetRoute + }, + }, }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/profile" @@ -29,20 +43,36 @@ app todoApp { setupFn: import setup from "@server/serverSetup.js" }, client: { - rootComponent: import { App } from "@client/App.jsx", - setupFn: import setup from "@client/clientSetup" + rootComponent: import { App } from "@client/App.tsx", + setupFn: import setup from "@client/clientSetup.js" }, db: { - system: PostgreSQL - } + system: PostgreSQL, + seeds: [ + import { devSeedSimple } from "@server/dbSeeds.js", + import { prodSeed } from "@server/dbSeeds.js" + ] + }, + emailSender: { + provider: SMTP, + defaultFrom: { + email: "mihovil@ilakovac.com" + }, + }, } entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String - tasks Task[] + // Email auth + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + // Social login externalAuthAssociations SocialLogin[] + // Business logic + tasks Task[] psl=} entity SocialLogin {=psl @@ -65,12 +95,27 @@ psl=} route SignupRoute { path: "/signup", to: SignupPage } page SignupPage { - component: import Signup from "@client/pages/auth/Signup.jsx" + component: import Signup from "@client/pages/auth/Signup.tsx" } route LoginRoute { path: "/login", to: LoginPage } page LoginPage { - component: import Login from "@client/pages/auth/Login.jsx" + component: import Login from "@client/pages/auth/Login.tsx" +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/pages/auth/PasswordReset.tsx", +} + +route EmailVerificationRoute { path: "/email-verification-", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/pages/auth/EmailVerification.tsx", +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { RequestPasswordReset } from "@client/pages/auth/RequestPasswordReset.tsx", } route HomeRoute { path: "/", to: MainPage } @@ -105,6 +150,19 @@ query getTasks { entities: [Task] } +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar") +} + +api barBaz { + fn: import { barBaz } from "@server/apis.js", + auth: false, + entities: [Task], + httpRoute: (GET, "/bar/baz") +} + query getNumTasks { fn: import { getNumTasks } from "@server/queries.js", entities: [Task], @@ -116,6 +174,10 @@ query getTask { entities: [Task] } +query getDate { + fn: import { getDate } from "@server/queries.js" +} + // --------- Actions --------- // action createTask { diff --git a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs index 030f47366..c36b4a51d 100644 --- a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs +++ b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs @@ -11,22 +11,27 @@ import Wasp.Analyzer.StdTypeDefinitions.Entity () import qualified Wasp.Analyzer.TypeDefinitions as TD import Wasp.Analyzer.TypeDefinitions.TH (makeDeclType, makeEnumType) import Wasp.AppSpec.Action (Action) +import Wasp.AppSpec.Api (Api, HttpMethod) import Wasp.AppSpec.App (App) import Wasp.AppSpec.App.Db (DbSystem) +import Wasp.AppSpec.App.EmailSender (EmailProvider) import Wasp.AppSpec.Entity (Entity) import Wasp.AppSpec.Job (Job, JobExecutor) import Wasp.AppSpec.Page (Page) import Wasp.AppSpec.Query (Query) import Wasp.AppSpec.Route (Route) +makeEnumType ''EmailProvider makeEnumType ''DbSystem -makeDeclType ''App makeDeclType ''Page makeDeclType ''Route makeDeclType ''Query makeDeclType ''Action makeEnumType ''JobExecutor makeDeclType ''Job +makeEnumType ''HttpMethod +makeDeclType ''Api +makeDeclType ''App {- ORMOLU_DISABLE -} -- | Collection of domain types that are standard for Wasp, that define what the Wasp language looks like. @@ -43,5 +48,8 @@ stdTypes = TD.addDeclType @Action $ TD.addEnumType @JobExecutor $ TD.addDeclType @Job $ + TD.addEnumType @HttpMethod $ + TD.addDeclType @Api $ + TD.addEnumType @EmailProvider $ TD.empty {- ORMOLU_ENABLE -} diff --git a/waspc/src/Wasp/AppSpec.hs b/waspc/src/Wasp/AppSpec.hs index 4843fdb86..5db9d01b4 100644 --- a/waspc/src/Wasp/AppSpec.hs +++ b/waspc/src/Wasp/AppSpec.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE TypeApplications #-} + module Wasp.AppSpec ( AppSpec (..), Decl, @@ -7,6 +9,7 @@ module Wasp.AppSpec refName, getActions, getQueries, + getApis, getEntities, getPages, getRoutes, @@ -14,6 +17,7 @@ module Wasp.AppSpec resolveRef, doesConfigFileExist, asAbsWaspProjectDirFile, + getApp, ) where @@ -22,6 +26,8 @@ import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import StrongPath (Abs, Dir, File', Path', Rel, ()) import Wasp.AppSpec.Action (Action) +import Wasp.AppSpec.Api (Api) +import Wasp.AppSpec.App (App) import Wasp.AppSpec.ConfigFile (ConfigFileRelocator (..)) import Wasp.AppSpec.Core.Decl (Decl, IsDecl, takeDecls) import Wasp.AppSpec.Core.Ref (Ref, refName) @@ -31,7 +37,9 @@ import Wasp.AppSpec.Job (Job) import Wasp.AppSpec.Page (Page) import Wasp.AppSpec.Query (Query) import Wasp.AppSpec.Route (Route) -import Wasp.Common (DbMigrationsDir, WaspProjectDir) +import Wasp.Env (EnvVar) +import Wasp.Project.Common (WaspProjectDir) +import Wasp.Project.Db.Migrations (DbMigrationsDir) -- | AppSpec is the main/central intermediate representation (IR) of the whole Wasp compiler, -- describing the web app specification with all the details needed to generate it. @@ -50,19 +58,20 @@ data AppSpec = AppSpec externalSharedFiles :: [ExternalCode.File], -- | Absolute path to the directory in wasp project source that contains external code files. migrationsDir :: Maybe (Path' Abs (Dir DbMigrationsDir)), - -- | Absolute path to the .env.server file in wasp project source. It contains env variables to be - -- provided to the server only during the development. - dotEnvServerFile :: Maybe (Path' Abs File'), - -- | Absolute path to the .env.client file in wasp project source. It contains env variables to be - -- provided to the client only during the development. - dotEnvClientFile :: Maybe (Path' Abs File'), + -- | Env variables to be provided to the server only during the development. + devEnvVarsServer :: [EnvVar], + -- | Env variables to be provided to the client only during the development. + devEnvVarsClient :: [EnvVar], -- | If true, it means project is being compiled for production/deployment -> it is being "built". -- If false, it means project is being compiled for development purposes (e.g. "wasp start"). isBuild :: Bool, -- | The contents of the optional user Dockerfile found in the root of the wasp project source. userDockerfileContents :: Maybe Text, -- | A list of paths to any config files found (e.g., tailwind.config.cjs) and where to copy them. - configFiles :: [ConfigFileRelocator] + configFiles :: [ConfigFileRelocator], + -- | Connection URL for a database used during development. If provided, generated app will + -- make sure to use it when run in development mode. + devDatabaseUrl :: Maybe String } -- TODO: Make this return "Named" declarations? @@ -79,6 +88,9 @@ getQueries = getDecls getActions :: AppSpec -> [(String, Action)] getActions = getDecls +getApis :: AppSpec -> [(String, Api)] +getApis = getDecls + getEntities :: AppSpec -> [(String, Entity)] getEntities = getDecls @@ -91,6 +103,11 @@ getRoutes = getDecls getJobs :: AppSpec -> [(String, Job)] getJobs = getDecls +getApp :: [Decl] -> Maybe (String, App) +getApp dcls = case takeDecls @App dcls of + [] -> Nothing + apps -> Just $ head apps + resolveRef :: (IsDecl d) => AppSpec -> Ref d -> (String, d) resolveRef spec ref = fromMaybe diff --git a/waspc/src/Wasp/AppSpec/Api.hs b/waspc/src/Wasp/AppSpec/Api.hs new file mode 100644 index 000000000..2c8179505 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/Api.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.Api + ( Api (..), + HttpMethod (..), + method, + path, + ) +where + +import Data.Data (Data) +import Wasp.AppSpec.Core.Decl (IsDecl) +import Wasp.AppSpec.Core.Ref (Ref) +import Wasp.AppSpec.Entity (Entity) +import Wasp.AppSpec.ExtImport (ExtImport) + +data Api = Api + { fn :: ExtImport, + entities :: Maybe [Ref Entity], + httpRoute :: (HttpMethod, String), -- (method, path), exe: (GET, "/foo/bar") + auth :: Maybe Bool + } + deriving (Show, Eq, Data) + +instance IsDecl Api + +method :: Api -> HttpMethod +method = fst . httpRoute + +path :: Api -> String +path = snd . httpRoute + +data HttpMethod = ALL | GET | POST | PUT | DELETE + deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App.hs b/waspc/src/Wasp/AppSpec/App.hs index 4d20f6225..da202d329 100644 --- a/waspc/src/Wasp/AppSpec/App.hs +++ b/waspc/src/Wasp/AppSpec/App.hs @@ -7,6 +7,7 @@ import Wasp.AppSpec.App.Auth (Auth) import Wasp.AppSpec.App.Client (Client) import Wasp.AppSpec.App.Db (Db) import Wasp.AppSpec.App.Dependency (Dependency) +import Wasp.AppSpec.App.EmailSender (EmailSender) import Wasp.AppSpec.App.Server (Server) import Wasp.AppSpec.App.Wasp (Wasp) import Wasp.AppSpec.Core.Decl (IsDecl) @@ -19,6 +20,7 @@ data App = App server :: Maybe Server, client :: Maybe Client, db :: Maybe Db, + emailSender :: Maybe EmailSender, dependencies :: Maybe [Dependency] } deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index f17e3fc26..1ba4c8e01 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -5,16 +5,22 @@ module Wasp.AppSpec.App.Auth ( Auth (..), AuthMethods (..), ExternalAuthConfig (..), + EmailAuthConfig (..), usernameAndPasswordConfig, isUsernameAndPasswordAuthEnabled, isExternalAuthEnabled, isGoogleAuthEnabled, isGitHubAuthEnabled, + isEmailAuthEnabled, + isEmailVerificationRequired, ) where import Data.Data (Data) import Data.Maybe (isJust) +import Wasp.AppSpec.App.Auth.EmailVerification (EmailVerificationConfig) +import Wasp.AppSpec.App.Auth.PasswordReset (PasswordResetConfig) +import Wasp.AppSpec.App.EmailSender (EmailFromField) import Wasp.AppSpec.Core.Ref (Ref) import Wasp.AppSpec.Entity (Entity) import Wasp.AppSpec.ExtImport (ExtImport) @@ -31,7 +37,8 @@ data Auth = Auth data AuthMethods = AuthMethods { usernameAndPassword :: Maybe UsernameAndPasswordConfig, google :: Maybe ExternalAuthConfig, - gitHub :: Maybe ExternalAuthConfig + gitHub :: Maybe ExternalAuthConfig, + email :: Maybe EmailAuthConfig } deriving (Show, Eq, Data) @@ -47,6 +54,14 @@ data ExternalAuthConfig = ExternalAuthConfig } deriving (Show, Eq, Data) +data EmailAuthConfig = EmailAuthConfig + { fromField :: EmailFromField, + emailVerification :: EmailVerificationConfig, + passwordReset :: PasswordResetConfig, + allowUnverifiedLogin :: Maybe Bool + } + deriving (Show, Eq, Data) + usernameAndPasswordConfig :: UsernameAndPasswordConfig usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing @@ -61,3 +76,11 @@ isGoogleAuthEnabled = isJust . google . methods isGitHubAuthEnabled :: Auth -> Bool isGitHubAuthEnabled = isJust . gitHub . methods + +isEmailAuthEnabled :: Auth -> Bool +isEmailAuthEnabled = isJust . email . methods + +isEmailVerificationRequired :: Auth -> Bool +isEmailVerificationRequired auth = case email . methods $ auth of + Nothing -> False + Just emailAuthConfig -> allowUnverifiedLogin emailAuthConfig /= Just True diff --git a/waspc/src/Wasp/AppSpec/App/Auth/EmailVerification.hs b/waspc/src/Wasp/AppSpec/App/Auth/EmailVerification.hs new file mode 100644 index 000000000..c81282d36 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/App/Auth/EmailVerification.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.App.Auth.EmailVerification where + +import Data.Data (Data) +import Wasp.AppSpec.Core.Ref (Ref) +import Wasp.AppSpec.ExtImport (ExtImport) +import Wasp.AppSpec.Route (Route) + +data EmailVerificationConfig = EmailVerificationConfig + { getEmailContentFn :: Maybe ExtImport, + clientRoute :: Ref Route + } + deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/Auth/PasswordReset.hs b/waspc/src/Wasp/AppSpec/App/Auth/PasswordReset.hs new file mode 100644 index 000000000..062354a84 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/App/Auth/PasswordReset.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.App.Auth.PasswordReset where + +import Data.Data (Data) +import Wasp.AppSpec.Core.Ref (Ref) +import Wasp.AppSpec.ExtImport (ExtImport) +import Wasp.AppSpec.Route (Route) + +data PasswordResetConfig = PasswordResetConfig + { getEmailContentFn :: Maybe ExtImport, + clientRoute :: Ref Route + } + deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/Db.hs b/waspc/src/Wasp/AppSpec/App/Db.hs index 5cc584671..1a07e69c2 100644 --- a/waspc/src/Wasp/AppSpec/App/Db.hs +++ b/waspc/src/Wasp/AppSpec/App/Db.hs @@ -7,9 +7,11 @@ module Wasp.AppSpec.App.Db where import Data.Data (Data) +import Wasp.AppSpec.ExtImport (ExtImport) data Db = Db - { system :: Maybe DbSystem + { system :: Maybe DbSystem, + seeds :: Maybe [ExtImport] } deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/EmailSender.hs b/waspc/src/Wasp/AppSpec/App/EmailSender.hs new file mode 100644 index 000000000..ee10a4090 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/App/EmailSender.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.App.EmailSender + ( EmailSender (..), + EmailProvider (..), + EmailFromField (..), + ) +where + +import Data.Data (Data) + +data EmailSender = EmailSender + { provider :: EmailProvider, + defaultFrom :: Maybe EmailFromField + } + deriving (Show, Eq, Data) + +data EmailProvider = SMTP | SendGrid | Mailgun + deriving (Eq, Data, Show) + +data EmailFromField = EmailFromField + { name :: Maybe String, + email :: String + } + deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/ConfigFile.hs b/waspc/src/Wasp/AppSpec/ConfigFile.hs index 188fcefb4..8397ab20d 100644 --- a/waspc/src/Wasp/AppSpec/ConfigFile.hs +++ b/waspc/src/Wasp/AppSpec/ConfigFile.hs @@ -4,8 +4,8 @@ module Wasp.AppSpec.ConfigFile where import StrongPath (File', Path', Rel) -import Wasp.Common (WaspProjectDir) import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Project.Common (WaspProjectDir) -- | A type for establishing the mapping of where to copy config files from/to. data ConfigFileRelocator = ConfigFileRelocator diff --git a/waspc/src/Wasp/AppSpec/ExtImport.hs b/waspc/src/Wasp/AppSpec/ExtImport.hs index 22e3cbcd1..0f9ec1719 100644 --- a/waspc/src/Wasp/AppSpec/ExtImport.hs +++ b/waspc/src/Wasp/AppSpec/ExtImport.hs @@ -3,6 +3,7 @@ module Wasp.AppSpec.ExtImport ( ExtImport (..), ExtImportName (..), + importIdentifier, ) where @@ -28,3 +29,8 @@ data ExtImportName | -- | Represents external imports like @import { Identifier } from "file.js"@ ExtImportField Identifier deriving (Show, Eq, Data) + +importIdentifier :: ExtImport -> Identifier +importIdentifier (ExtImport importName _) = case importName of + ExtImportModule n -> n + ExtImportField n -> n diff --git a/waspc/src/Wasp/AppSpec/Util.hs b/waspc/src/Wasp/AppSpec/Util.hs index 62f5f2634..712fbccf9 100644 --- a/waspc/src/Wasp/AppSpec/Util.hs +++ b/waspc/src/Wasp/AppSpec/Util.hs @@ -1,8 +1,20 @@ -module Wasp.AppSpec.Util (isPgBossJobExecutorUsed) where +module Wasp.AppSpec.Util + ( isPgBossJobExecutorUsed, + getRoutePathFromRef, + ) +where import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.Core.Ref as AS.Ref import qualified Wasp.AppSpec.Job as Job +import qualified Wasp.AppSpec.Route as AS.Route isPgBossJobExecutorUsed :: AppSpec -> Bool isPgBossJobExecutorUsed spec = any (\(_, job) -> Job.executor job == Job.PgBoss) (AS.getJobs spec) + +getRoutePathFromRef :: AS.AppSpec -> AS.Ref.Ref AS.Route.Route -> String +getRoutePathFromRef spec ref = path + where + route = AS.resolveRef spec ref + path = AS.Route.path . snd $ route diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index a87f9c033..934d77f66 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -5,6 +5,7 @@ module Wasp.AppSpec.Valid ValidationError (..), getApp, isAuthEnabled, + doesUserEntityContainField, ) where @@ -44,7 +45,10 @@ validateAppSpec spec = concat [ validateWasp spec, validateAppAuthIsSetIfAnyPageRequiresAuth spec, + validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec, validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec, + validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec, + validateEmailSenderIsDefinedIfEmailAuthIsUsed spec, validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec, validateDbIsPostgresIfPgBossUsed spec ] @@ -110,6 +114,18 @@ validateAppAuthIsSetIfAnyPageRequiresAuth spec = where anyPageRequiresAuth = any ((== Just True) . Page.authRequired) (snd <$> AS.getPages spec) +validateOnlyEmailOrUsernameAndPasswordAuthIsUsed :: AppSpec -> [ValidationError] +validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec = + case App.auth (snd $ getApp spec) of + Nothing -> [] + Just auth -> + [ GenericValidationError + "Expected app.auth to use either email or username and password authentication, but not both." + | areBothAuthMethodsUsed + ] + where + areBothAuthMethodsUsed = Auth.isEmailAuthEnabled auth && Auth.isUsernameAndPasswordAuthEnabled auth + validateDbIsPostgresIfPgBossUsed :: AppSpec -> [ValidationError] validateDbIsPostgresIfPgBossUsed spec = [ GenericValidationError @@ -132,6 +148,36 @@ validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec = cas ("password", Entity.Field.FieldTypeScalar Entity.Field.String, "String") ] +validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed :: AppSpec -> [ValidationError] +validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec = case App.auth (snd $ getApp spec) of + Nothing -> [] + Just auth -> + if not $ Auth.isEmailAuthEnabled auth + then [] + else + let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth) + userEntityFields = Entity.getFields userEntity + in concatMap + (validateEntityHasField "app.auth.userEntity" userEntityFields) + [ ("email", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"), + ("password", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"), + ("isEmailVerified", Entity.Field.FieldTypeScalar Entity.Field.Boolean, "Boolean"), + ("emailVerificationSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?"), + ("passwordResetSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?") + ] + +validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError] +validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of + Nothing -> [] + Just auth -> + if not $ Auth.isEmailAuthEnabled auth + then [] + else case App.emailSender app of + Nothing -> [GenericValidationError "app.emailSender must be specified when using email auth."] + Just _ -> [] + where + app = snd $ getApp spec + validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed :: AppSpec -> [ValidationError] validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec = case App.auth (snd $ getApp spec) of Nothing -> [] @@ -186,6 +232,19 @@ getApp spec = case takeDecls @App (AS.decls spec) of isAuthEnabled :: AppSpec -> Bool isAuthEnabled spec = isJust (App.auth $ snd $ getApp spec) +-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function). +getDbSystem :: AppSpec -> Maybe AS.Db.DbSystem +getDbSystem spec = AS.Db.system =<< AS.App.db (snd $ getApp spec) + -- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function). isPostgresUsed :: AppSpec -> Bool -isPostgresUsed spec = Just AS.Db.PostgreSQL == (AS.Db.system =<< AS.App.db (snd $ getApp spec)) +isPostgresUsed = (Just AS.Db.PostgreSQL ==) . getDbSystem + +-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function). +-- If there is no user entity, it returns Nothing. +doesUserEntityContainField :: AppSpec -> String -> Maybe Bool +doesUserEntityContainField spec fieldName = do + auth <- App.auth (snd $ getApp spec) + let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth) + let userEntityFields = Entity.getFields userEntity + Just $ any (\field -> Entity.Field.fieldName field == fieldName) userEntityFields diff --git a/waspc/src/Wasp/Common.hs b/waspc/src/Wasp/Common.hs deleted file mode 100644 index 19c311ca6..000000000 --- a/waspc/src/Wasp/Common.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Wasp.Common - ( DbMigrationsDir, - WaspProjectDir, - dbMigrationsDirInWaspProjectDir, - ) -where - -import StrongPath (Dir, Path', Rel, reldir) - -data WaspProjectDir -- Root dir of Wasp project, containing source files. - -data DbMigrationsDir - -dbMigrationsDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir DbMigrationsDir) -dbMigrationsDirInWaspProjectDir = [reldir|migrations|] diff --git a/waspc/src/Wasp/ConfigFile.hs b/waspc/src/Wasp/ConfigFile.hs index e13d41d9b..88e18907d 100644 --- a/waspc/src/Wasp/ConfigFile.hs +++ b/waspc/src/Wasp/ConfigFile.hs @@ -8,8 +8,8 @@ import qualified Data.Map as Data import Data.Maybe (mapMaybe) import StrongPath (Abs, Dir, File', Path', Rel) import Wasp.AppSpec.ConfigFile (ConfigFileRelocator (..)) -import Wasp.Common (WaspProjectDir) import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Project.Common (WaspProjectDir) import qualified Wasp.Util.IO as Util.IO type ConfigFileRelocationMap = Data.Map (Path' (Rel WaspProjectDir) File') (Path' (Rel ProjectRootDir) File') diff --git a/waspc/src/Wasp/Db/Postgres.hs b/waspc/src/Wasp/Db/Postgres.hs new file mode 100644 index 000000000..b42b0a807 --- /dev/null +++ b/waspc/src/Wasp/Db/Postgres.hs @@ -0,0 +1,15 @@ +module Wasp.Db.Postgres + ( makeConnectionUrl, + postgresMaxDbNameLength, + ) +where + +import Text.Printf (printf) + +makeConnectionUrl :: String -> String -> Int -> String -> String +makeConnectionUrl user pass port dbName = + printf "postgresql://%s:%s@localhost:%d/%s" user pass port dbName + +-- As specified by PostgreSQL documentation. +postgresMaxDbNameLength :: Int +postgresMaxDbNameLength = 63 diff --git a/waspc/src/Wasp/Env.hs b/waspc/src/Wasp/Env.hs new file mode 100644 index 000000000..55d805616 --- /dev/null +++ b/waspc/src/Wasp/Env.hs @@ -0,0 +1,36 @@ +-- | This modules implements general concepts regarding env vars. +-- It is not specific to Wasp in any way. +module Wasp.Env + ( EnvVar, + EnvVarName, + EnvVarValue, + parseDotEnvFile, + envVarsToDotEnvContent, + ) +where + +import qualified Configuration.Dotenv as Dotenv +import Control.Exception (ErrorCall (ErrorCall)) +import Data.List (intercalate) +import qualified Data.Text as T +import StrongPath (Abs, File, Path', fromAbsFile) +import UnliftIO.Exception (catch, throwIO) + +type EnvVar = (EnvVarName, EnvVarValue) + +type EnvVarName = String + +type EnvVarValue = String + +-- Reads specified dotenv file and returns its values. +-- Crashes if file doesn't exist or it can't parse it. +parseDotEnvFile :: Path' Abs (File ()) -> IO [EnvVar] +parseDotEnvFile envFile = + Dotenv.parseFile (fromAbsFile envFile) + -- Parse errors are returned from Dotenv.parseFile as ErrorCall, which Wasp compiler would + -- report as a bug in compiler, so we instead convert these to IOExceptions. + `catch` \(ErrorCall msg) -> throwIO $ userError $ "Failed to parse dot env file: " <> msg + +envVarsToDotEnvContent :: [EnvVar] -> T.Text +envVarsToDotEnvContent vars = + T.pack $ intercalate "\n" $ map (\(name, value) -> name <> "=" <> show value) vars diff --git a/waspc/src/Wasp/Generator.hs b/waspc/src/Wasp/Generator.hs index 904ff26fa..ab60e1c1d 100644 --- a/waspc/src/Wasp/Generator.hs +++ b/waspc/src/Wasp/Generator.hs @@ -1,6 +1,8 @@ module Wasp.Generator ( writeWebAppCode, Wasp.Generator.Start.start, + Wasp.Generator.Test.testWebApp, + ProjectRootDir, ) where @@ -22,6 +24,7 @@ import Wasp.Generator.Monad (Generator, GeneratorError, GeneratorWarning, runGen import Wasp.Generator.ServerGenerator (genServer) import Wasp.Generator.Setup (runSetup) import qualified Wasp.Generator.Start +import qualified Wasp.Generator.Test import Wasp.Generator.WebAppGenerator (genWebApp) import Wasp.Generator.WriteFileDrafts (synchronizeFileDraftsWithDisk) import Wasp.Message (SendMessage) diff --git a/waspc/src/Wasp/Generator/AuthProviders.hs b/waspc/src/Wasp/Generator/AuthProviders.hs new file mode 100644 index 000000000..917f20b9e --- /dev/null +++ b/waspc/src/Wasp/Generator/AuthProviders.hs @@ -0,0 +1,40 @@ +module Wasp.Generator.AuthProviders where + +import Data.Maybe (fromJust) +import qualified Wasp.AppSpec.App.Dependency as App.Dependency +import Wasp.Generator.AuthProviders.Common (makeProviderId) +import qualified Wasp.Generator.AuthProviders.Email as E +import qualified Wasp.Generator.AuthProviders.Local as L +import qualified Wasp.Generator.AuthProviders.OAuth as OA + +googleAuthProvider :: OA.OAuthAuthProvider +googleAuthProvider = + OA.OAuthAuthProvider + { OA._providerId = fromJust $ makeProviderId "google", + OA._displayName = "Google", + OA._requiredScope = ["profile"], + OA._passportDependency = App.Dependency.make ("passport-google-oauth20", "2.0.0") + } + +gitHubAuthProvider :: OA.OAuthAuthProvider +gitHubAuthProvider = + OA.OAuthAuthProvider + { OA._providerId = fromJust $ makeProviderId "github", + OA._displayName = "GitHub", + OA._requiredScope = [], + OA._passportDependency = App.Dependency.make ("passport-github2", "0.1.12") + } + +localAuthProvider :: L.LocalAuthProvider +localAuthProvider = + L.LocalAuthProvider + { L._providerId = fromJust $ makeProviderId "local", + L._displayName = "Username and password" + } + +emailAuthProvider :: E.EmailAuthProvider +emailAuthProvider = + E.EmailAuthProvider + { E._providerId = fromJust $ makeProviderId "email", + E._displayName = "Email and password" + } diff --git a/waspc/src/Wasp/Generator/AuthProviders/Common.hs b/waspc/src/Wasp/Generator/AuthProviders/Common.hs new file mode 100644 index 000000000..6eb146a1e --- /dev/null +++ b/waspc/src/Wasp/Generator/AuthProviders/Common.hs @@ -0,0 +1,24 @@ +module Wasp.Generator.AuthProviders.Common + ( ProviderId, + makeProviderId, + fromProviderId, + ) +where + +-- Unique identifier of the auth provider, it's used in the +-- URL of the login page, e.g. /auth/{providerId}/login, so it should +-- include only characters that are allowed in URLs +data ProviderId = ProviderId String + deriving (Show, Eq) + +-- Create ProviderId from String, but only if it's valid +-- (i.e. it contains only a-z and dashes and it's not empty). +makeProviderId :: String -> Maybe ProviderId +makeProviderId s + | s == "" = Nothing + | otherwise = if all isValidChar s then Just (ProviderId s) else Nothing + where + isValidChar c = c `elem` ['a' .. 'z'] || c == '-' + +fromProviderId :: ProviderId -> String +fromProviderId (ProviderId s) = s diff --git a/waspc/src/Wasp/Generator/AuthProviders/Email.hs b/waspc/src/Wasp/Generator/AuthProviders/Email.hs new file mode 100644 index 000000000..16abb2ac6 --- /dev/null +++ b/waspc/src/Wasp/Generator/AuthProviders/Email.hs @@ -0,0 +1,41 @@ +module Wasp.Generator.AuthProviders.Email + ( providerId, + displayName, + serverLoginUrl, + serverSignupUrl, + serverRequestPasswordResetUrl, + serverResetPasswordUrl, + serverVerifyEmailUrl, + EmailAuthProvider (..), + ) +where + +import Wasp.Generator.AuthProviders.Common (ProviderId, fromProviderId) + +data EmailAuthProvider = EmailAuthProvider + { -- Unique identifier of the auth provider + _providerId :: ProviderId, + -- Used for pretty printing + _displayName :: String + } + +providerId :: EmailAuthProvider -> String +providerId = fromProviderId . _providerId + +displayName :: EmailAuthProvider -> String +displayName = _displayName + +serverLoginUrl :: EmailAuthProvider -> String +serverLoginUrl provider = "/auth/" ++ providerId provider ++ "/login" + +serverSignupUrl :: EmailAuthProvider -> String +serverSignupUrl provider = "/auth/" ++ providerId provider ++ "/signup" + +serverRequestPasswordResetUrl :: EmailAuthProvider -> String +serverRequestPasswordResetUrl provider = "/auth/" ++ providerId provider ++ "/request-password-reset" + +serverResetPasswordUrl :: EmailAuthProvider -> String +serverResetPasswordUrl provider = "/auth/" ++ providerId provider ++ "/reset-password" + +serverVerifyEmailUrl :: EmailAuthProvider -> String +serverVerifyEmailUrl provider = "/auth/" ++ providerId provider ++ "/verify-email" diff --git a/waspc/src/Wasp/Generator/AuthProviders/Local.hs b/waspc/src/Wasp/Generator/AuthProviders/Local.hs new file mode 100644 index 000000000..970972f9d --- /dev/null +++ b/waspc/src/Wasp/Generator/AuthProviders/Local.hs @@ -0,0 +1,29 @@ +module Wasp.Generator.AuthProviders.Local + ( providerId, + displayName, + serverLoginUrl, + serverSignupUrl, + LocalAuthProvider (..), + ) +where + +import Wasp.Generator.AuthProviders.Common (ProviderId, fromProviderId) + +data LocalAuthProvider = LocalAuthProvider + { -- Unique identifier of the auth provider + _providerId :: ProviderId, + -- Used for pretty printing + _displayName :: String + } + +providerId :: LocalAuthProvider -> String +providerId = fromProviderId . _providerId + +displayName :: LocalAuthProvider -> String +displayName = _displayName + +serverLoginUrl :: LocalAuthProvider -> String +serverLoginUrl provider = "/auth/" ++ providerId provider ++ "/login" + +serverSignupUrl :: LocalAuthProvider -> String +serverSignupUrl provider = "/auth/" ++ providerId provider ++ "/signup" diff --git a/waspc/src/Wasp/Generator/AuthProviders/OAuth.hs b/waspc/src/Wasp/Generator/AuthProviders/OAuth.hs new file mode 100644 index 000000000..95c7b7848 --- /dev/null +++ b/waspc/src/Wasp/Generator/AuthProviders/OAuth.hs @@ -0,0 +1,61 @@ +module Wasp.Generator.AuthProviders.OAuth + ( frontendLoginUrl, + serverLoginUrl, + serverOauthRedirectHandlerUrl, + providerId, + displayName, + passportDependency, + scopeStr, + clientIdEnvVarName, + clientSecretEnvVarName, + OAuthAuthProvider (..), + ) +where + +import Data.Char (toUpper) +import Wasp.AppSpec.App.Dependency (Dependency) +import Wasp.Generator.AuthProviders.Common (ProviderId, fromProviderId) +import Wasp.Generator.Common (makeJsArrayFromHaskellList) + +data OAuthAuthProvider = OAuthAuthProvider + { -- Unique identifier of the auth provider + _providerId :: ProviderId, + -- Used for pretty printing + _displayName :: String, + _requiredScope :: OAuthScope, + _passportDependency :: Dependency + } + +type OAuthScope = [String] + +providerId :: OAuthAuthProvider -> String +providerId = fromProviderId . _providerId + +displayName :: OAuthAuthProvider -> String +displayName = _displayName + +clientIdEnvVarName :: OAuthAuthProvider -> String +clientIdEnvVarName oai = upperCaseId oai ++ "_CLIENT_ID" + +clientSecretEnvVarName :: OAuthAuthProvider -> String +clientSecretEnvVarName oai = upperCaseId oai ++ "_CLIENT_SECRET" + +upperCaseId :: OAuthAuthProvider -> String +upperCaseId oai = map toUpper (providerId oai) + +-- Generates the string used in JS e.g. ["profile"] list in Haskell becomes "[\"profile\"]" +-- string which can be outputted in JS code verbatim. +scopeStr :: OAuthAuthProvider -> String +scopeStr oai = makeJsArrayFromHaskellList $ _requiredScope oai + +passportDependency :: OAuthAuthProvider -> Dependency +passportDependency = _passportDependency + +frontendLoginUrl :: OAuthAuthProvider -> String +frontendLoginUrl oai = "/auth/login/" ++ providerId oai + +serverLoginUrl :: OAuthAuthProvider -> String +serverLoginUrl oai = "/auth/" ++ providerId oai ++ "/login" + +serverOauthRedirectHandlerUrl :: OAuthAuthProvider -> String +serverOauthRedirectHandlerUrl oai = "/auth/" ++ providerId oai ++ "/callback" diff --git a/waspc/src/Wasp/Generator/Common.hs b/waspc/src/Wasp/Generator/Common.hs index 8e3c58ad7..f23d9563d 100644 --- a/waspc/src/Wasp/Generator/Common.hs +++ b/waspc/src/Wasp/Generator/Common.hs @@ -11,11 +11,13 @@ module Wasp.Generator.Common prismaVersion, makeJsonWithEntityData, GeneratedSrcDir, + makeJsArrayFromHaskellList, ) where import Data.Aeson (KeyValue ((.=)), object) import qualified Data.Aeson as Aeson +import Data.List (intercalate) import StrongPath (Dir, Rel, reldir) import StrongPath.Types (Path') import Wasp.Generator.Templates (TemplatesDir) @@ -63,7 +65,7 @@ latestNodeLTSVersion :: SV.Version latestNodeLTSVersion = SV.Version 18 12 0 prismaVersion :: SV.Version -prismaVersion = SV.Version 4 5 0 +prismaVersion = SV.Version 4 12 0 makeJsonWithEntityData :: String -> Aeson.Value makeJsonWithEntityData name = @@ -80,3 +82,8 @@ makeJsonWithEntityData name = -- `context.entities` JS objects in Wasp templates. entityNameToPrismaIdentifier :: String -> String entityNameToPrismaIdentifier = toLowerFirst + +makeJsArrayFromHaskellList :: Show a => [a] -> String +makeJsArrayFromHaskellList list = "[" ++ intercalate ", " listOfJsStrings ++ "]" + where + listOfJsStrings = map (\x -> "'" ++ show x ++ "'") list diff --git a/waspc/src/Wasp/Generator/ConfigFile.hs b/waspc/src/Wasp/Generator/ConfigFile.hs index 21135bbb1..59cb15781 100644 --- a/waspc/src/Wasp/Generator/ConfigFile.hs +++ b/waspc/src/Wasp/Generator/ConfigFile.hs @@ -7,10 +7,10 @@ where import Data.Map (fromList) import StrongPath (File', Path', Rel, castRel, relfile, ()) import Wasp.AppSpec (AppSpec, doesConfigFileExist) -import Wasp.Common (WaspProjectDir) import Wasp.ConfigFile (ConfigFileRelocationMap) import Wasp.Generator.Common (ProjectRootDir) import Wasp.Generator.WebAppGenerator.Common (webAppRootDirInProjectRootDir) +import Wasp.Project.Common (WaspProjectDir) tailwindConfigFile :: Path' (Rel WaspProjectDir) File' tailwindConfigFile = [relfile|tailwind.config.cjs|] diff --git a/waspc/src/Wasp/Generator/DbGenerator.hs b/waspc/src/Wasp/Generator/DbGenerator.hs index 813cf6dd8..2cde367d4 100644 --- a/waspc/src/Wasp/Generator/DbGenerator.hs +++ b/waspc/src/Wasp/Generator/DbGenerator.hs @@ -23,7 +23,6 @@ import Wasp.Generator.DbGenerator.Common ( DbSchemaChecksumFile, DbSchemaChecksumOnLastDbConcurrenceFile, PrismaDbSchema, - databaseUrlEnvVar, dbMigrationsDirInDbRootDir, dbRootDirInProjectRootDir, dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir, @@ -41,6 +40,7 @@ import Wasp.Generator.Monad GeneratorWarning (GeneratorNeedsMigrationWarning), logAndThrowGeneratorError, ) +import Wasp.Project.Db (databaseUrlEnvVarName) import qualified Wasp.Psl.Ast.Model as Psl.Ast.Model import qualified Wasp.Psl.Generator.Model as Psl.Generator.Model import Wasp.Util (checksumFromFilePath, hexToString, ifM, (<:>)) @@ -56,7 +56,7 @@ genPrismaSchema :: Generator FileDraft genPrismaSchema spec = do (datasourceProvider :: String, datasourceUrl) <- case dbSystem of - AS.Db.PostgreSQL -> return ("postgresql", makeEnvVarField databaseUrlEnvVar) + AS.Db.PostgreSQL -> return ("postgresql", makeEnvVarField databaseUrlEnvVarName) AS.Db.SQLite -> if AS.isBuild spec then logAndThrowGeneratorError $ GenericGeneratorError "SQLite (a default database) is not supported in production. To build your Wasp app for production, switch to a different database. Switching to PostgreSQL: https://wasp-lang.dev/docs/language/features#migrating-from-sqlite-to-postgresql ." @@ -67,12 +67,12 @@ genPrismaSchema spec = do [ "modelSchemas" .= map entityToPslModelSchema (AS.getDecls @AS.Entity.Entity spec), "datasourceProvider" .= datasourceProvider, "datasourceUrl" .= datasourceUrl, - "prismaClientOutputDir" .= makeEnvVarField prismaClientOutputDirEnvVar + "prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar ] - return $ createTemplateFileDraft dbSchemaFileInProjectRootDir tmplSrcPath (Just templateData) + return $ createTemplateFileDraft Wasp.Generator.DbGenerator.Common.dbSchemaFileInProjectRootDir tmplSrcPath (Just templateData) where - tmplSrcPath = dbTemplatesDirInTemplatesDir dbSchemaFileInDbTemplatesDir + tmplSrcPath = Wasp.Generator.DbGenerator.Common.dbTemplatesDirInTemplatesDir Wasp.Generator.DbGenerator.Common.dbSchemaFileInDbTemplatesDir dbSystem = fromMaybe AS.Db.SQLite $ AS.Db.system =<< AS.App.db (snd $ getApp spec) makeEnvVarField envVarName = "env(\"" ++ envVarName ++ "\")" @@ -87,7 +87,7 @@ genMigrationsDir spec = AS.migrationsDir spec >>= \waspMigrationsDir -> Just $ createCopyDirFileDraft (SP.castDir genProjectMigrationsDir) (SP.castDir waspMigrationsDir) where - genProjectMigrationsDir = dbRootDirInProjectRootDir dbMigrationsDirInDbRootDir + genProjectMigrationsDir = Wasp.Generator.DbGenerator.Common.dbRootDirInProjectRootDir Wasp.Generator.DbGenerator.Common.dbMigrationsDirInDbRootDir -- | This function operates on generated code, and thus assumes the file drafts were written to disk postWriteDbGeneratorActions :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO ([GeneratorWarning], [GeneratorError]) @@ -125,19 +125,22 @@ warnIfDbNeedsMigration spec projectRootDir = do then warnProjectDiffersFromDb projectRootDir else return Nothing where - dbSchemaFp = projectRootDir dbSchemaFileInProjectRootDir - dbSchemaChecksumFp = projectRootDir dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir + dbSchemaFp = projectRootDir Wasp.Generator.DbGenerator.Common.dbSchemaFileInProjectRootDir + dbSchemaChecksumFp = projectRootDir Wasp.Generator.DbGenerator.Common.dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir entitiesExist = not . null $ getEntities spec warnIfSchemaDiffersFromChecksum :: - Path' Abs (File PrismaDbSchema) -> - Path' Abs (File DbSchemaChecksumOnLastDbConcurrenceFile) -> + Path' Abs (File Wasp.Generator.DbGenerator.Common.PrismaDbSchema) -> + Path' Abs (File Wasp.Generator.DbGenerator.Common.DbSchemaChecksumOnLastDbConcurrenceFile) -> IO (Maybe GeneratorWarning) warnIfSchemaDiffersFromChecksum dbSchemaFileAbs dbschemachecksumfile = ifM (checksumFileMatchesSchema dbSchemaFileAbs dbschemachecksumfile) (return Nothing) - (return . Just $ GeneratorNeedsMigrationWarning "Your Prisma schema has changed, please run `wasp db migrate-dev` when ready.") + ( return . Just $ + GeneratorNeedsMigrationWarning + "Your Prisma schema has changed, please run `wasp db migrate-dev` when ready." + ) -- | Checks if the project's Prisma schema file and migrations dir matches the DB state. -- Issues a warning if it cannot connect, or if either check fails. @@ -151,14 +154,22 @@ warnProjectDiffersFromDb projectRootDir = do then do -- NOTE: Since we know schema == db and all migrations are applied, -- we can write this file to prevent future redundant Prisma checks. - DbOps.writeDbSchemaChecksumToFile projectRootDir dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir + DbOps.writeDbSchemaChecksumToFile projectRootDir Wasp.Generator.DbGenerator.Common.dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir return Nothing - else return . Just $ GeneratorNeedsMigrationWarning "You have unapplied migrations. Please run `wasp db migrate-dev` when ready." - Just False -> return . Just $ GeneratorNeedsMigrationWarning "Your Prisma schema does not match your database, please run `wasp db migrate-dev`." - -- NOTE: If there was an error, it could mean we could not connect to the SQLite db, since it does not exist. - -- Or it could mean their databaseUrlEnvVar is wrong, or database is down, or any other number of causes. - -- In any case, migrating will either solve it (in the SQLite case), or allow Prisma to give them enough info to troubleshoot. - Nothing -> return . Just $ GeneratorNeedsMigrationWarning "Wasp was unable to verify your database is up to date. Running `wasp db migrate-dev` may fix this and will provide more info." + else + return . Just . GeneratorNeedsMigrationWarning $ + "You have unapplied migrations. Please run `wasp db migrate-dev` when ready." + Just False -> + return . Just . GeneratorNeedsMigrationWarning $ + "Your Prisma schema does not match your database, please run `wasp db migrate-dev`." + -- NOTE: If there was an error, it could mean we could not connect to the SQLite db, since it + -- does not exist. Or it could mean their databaseUrlEnvVar is wrong, or database is down, or + -- any other number of causes. In any case, migrating will either solve it (in the SQLite case), + -- or allow Prisma to give them enough info to troubleshoot. + Nothing -> + return . Just . GeneratorNeedsMigrationWarning $ + "Wasp was unable to verify your database is up to date." + <> " Running `wasp db migrate-dev` may fix this and will provide more info." genPrismaClients :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO (Maybe GeneratorError) genPrismaClients spec projectRootDir = @@ -169,7 +180,9 @@ genPrismaClients spec projectRootDir = where wasCurrentSchemaAlreadyGenerated :: IO Bool wasCurrentSchemaAlreadyGenerated = - checksumFileExistsAndMatchesSchema projectRootDir dbSchemaChecksumOnLastGenerateFileProjectRootDir + checksumFileExistsAndMatchesSchema + projectRootDir + Wasp.Generator.DbGenerator.Common.dbSchemaChecksumOnLastGenerateFileProjectRootDir generatePrismaClientsIfEntitiesExist :: IO (Maybe GeneratorError) generatePrismaClientsIfEntitiesExist @@ -180,7 +193,7 @@ genPrismaClients spec projectRootDir = entitiesExist = not . null $ getEntities spec checksumFileExistsAndMatchesSchema :: - DbSchemaChecksumFile f => + Wasp.Generator.DbGenerator.Common.DbSchemaChecksumFile f => Path' Abs (Dir ProjectRootDir) -> Path' (Rel ProjectRootDir) (File f) -> IO Bool @@ -190,16 +203,16 @@ checksumFileExistsAndMatchesSchema projectRootDir dbSchemaChecksumInProjectDir = (checksumFileMatchesSchema dbSchemaFileAbs checksumFileAbs) (return False) where - dbSchemaFileAbs = projectRootDir dbSchemaFileInProjectRootDir + dbSchemaFileAbs = projectRootDir Wasp.Generator.DbGenerator.Common.dbSchemaFileInProjectRootDir checksumFileAbs = projectRootDir dbSchemaChecksumInProjectDir -checksumFileMatchesSchema :: DbSchemaChecksumFile f => Path' Abs (File PrismaDbSchema) -> Path' Abs (File f) -> IO Bool +checksumFileMatchesSchema :: Wasp.Generator.DbGenerator.Common.DbSchemaChecksumFile f => Path' Abs (File Wasp.Generator.DbGenerator.Common.PrismaDbSchema) -> Path' Abs (File f) -> IO Bool checksumFileMatchesSchema dbSchemaFileAbs dbSchemaChecksumFileAbs = do -- Read file strictly as the checksum may be later overwritten. dbChecksumFileContents <- IOUtil.readFileStrict dbSchemaChecksumFileAbs schemaFileHasChecksum dbSchemaFileAbs dbChecksumFileContents where - schemaFileHasChecksum :: Path' Abs (File PrismaDbSchema) -> Text -> IO Bool + schemaFileHasChecksum :: Path' Abs (File Wasp.Generator.DbGenerator.Common.PrismaDbSchema) -> Text -> IO Bool schemaFileHasChecksum schemaFile checksum = do dbSchemaFileChecksum <- pack . hexToString <$> checksumFromFilePath schemaFile return $ dbSchemaFileChecksum == checksum diff --git a/waspc/src/Wasp/Generator/DbGenerator/Common.hs b/waspc/src/Wasp/Generator/DbGenerator/Common.hs index c4313501f..7fb95ecf7 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Common.hs @@ -20,16 +20,15 @@ module Wasp.Generator.DbGenerator.Common webAppRootDirFromDbRootDir, dbSchemaFileInProjectRootDir, prismaClientOutputDirEnvVar, - databaseUrlEnvVar, DbSchemaChecksumFile, ) where import StrongPath (Dir, File, File', Path', Rel, reldir, relfile, ()) import qualified StrongPath as SP -import Wasp.Common (DbMigrationsDir) import Wasp.Generator.Common (AppComponentRootDir, DbRootDir, ProjectRootDir, ServerRootDir) import Wasp.Generator.Templates (TemplatesDir) +import Wasp.Project.Db.Migrations (DbMigrationsDir) data DbTemplatesDir @@ -97,9 +96,6 @@ dbSchemaChecksumOnLastGenerateFileProjectRootDir = dbRootDirInProjectRootDir prismaClientOutputDirEnvVar :: String prismaClientOutputDirEnvVar = "PRISMA_CLIENT_OUTPUT_DIR" -databaseUrlEnvVar :: String -databaseUrlEnvVar = "DATABASE_URL" - prismaClientOutputDirInAppComponentDir :: AppComponentRootDir d => Path' (Rel d) (Dir ServerRootDir) prismaClientOutputDirInAppComponentDir = [reldir|node_modules/.prisma/client|] diff --git a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs index a1d38cae1..ea544d379 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs @@ -1,32 +1,35 @@ +{-# LANGUAGE TupleSections #-} + module Wasp.Generator.DbGenerator.Jobs ( migrateDev, migrateDiff, generatePrismaClient, runStudio, + reset, + seed, migrateStatus, asPrismaCliArgs, ) where -import StrongPath (Abs, Dir, File', Path', ()) +import StrongPath (Abs, Dir, File, File', Path', ()) import qualified StrongPath as SP import StrongPath.TH (relfile) import qualified System.Info import Wasp.Generator.Common (ProjectRootDir) import Wasp.Generator.DbGenerator.Common ( MigrateArgs (..), + PrismaDbSchema, dbSchemaFileInProjectRootDir, ) import Wasp.Generator.Job (JobType) import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.Process (runNodeCommandAsJob, runNodeCommandAsJobWithExtraEnv) import Wasp.Generator.ServerGenerator.Common (serverRootDirInProjectRootDir) +import Wasp.Generator.ServerGenerator.Db.Seed (dbSeedNameEnvVarName) migrateDev :: Path' Abs (Dir ProjectRootDir) -> MigrateArgs -> J.Job -migrateDev projectDir migrateArgs = do - let serverDir = projectDir serverRootDirInProjectRootDir - let schemaFile = projectDir dbSchemaFileInProjectRootDir - +migrateDev projectDir migrateArgs = -- NOTE(matija): We are running this command from server's root dir since that is where -- Prisma packages (cli and client) are currently installed. -- NOTE(martin): `prisma migrate dev` refuses to execute when interactivity is needed if stdout is being piped, @@ -34,25 +37,32 @@ migrateDev projectDir migrateArgs = do -- so we do have interactivity, but Prisma doesn't know that. -- I opened an issue with Prisma https://github.com/prisma/prisma/issues/7113, but in the meantime -- we are using `script` to trick Prisma into thinking it is running in TTY (interactively). - - -- NOTE(martin): For this to work on Mac, filepath in the list below must be as it is now - not wrapped in any quotes. - let prismaMigrateCmd = - [ absPrismaExecutableFp projectDir, - "migrate", - "dev", - "--schema", - SP.toFilePath schemaFile, - "--skip-generate" - ] - ++ asPrismaCliArgs migrateArgs - let scriptArgs = - if System.Info.os == "darwin" - then -- NOTE(martin): On MacOS, command that `script` should execute is treated as multiple arguments. - ["-Fq", "/dev/null"] ++ prismaMigrateCmd - else -- NOTE(martin): On Linux, command that `script` should execute is treated as one argument. - ["-feqc", unwords prismaMigrateCmd, "/dev/null"] - runNodeCommandAsJob serverDir "script" scriptArgs J.Db + where + serverDir = projectDir serverRootDirInProjectRootDir + schemaFile = projectDir dbSchemaFileInProjectRootDir + + scriptArgs = + if System.Info.os == "darwin" + then -- NOTE(martin): On MacOS, command that `script` should execute is treated as multiple arguments. + ["-Fq", "/dev/null"] ++ prismaMigrateCmd + else -- NOTE(martin): On Linux, command that `script` should execute is treated as one argument. + ["-feqc", unwords prismaMigrateCmd, "/dev/null"] + + -- NOTE(martin): For this to work on Mac, filepath in the list below must be as it is now - not + -- wrapped in any quotes. + -- NOTE(martin): We do "--skip-seed" here because I just think seeding happening automatically + -- in some situations is too aggressive / confusing. + prismaMigrateCmd = + [ absPrismaExecutableFp projectDir, + "migrate", + "dev", + "--schema", + SP.fromAbsFile schemaFile, + "--skip-generate", + "--skip-seed" + ] + ++ asPrismaCliArgs migrateArgs asPrismaCliArgs :: MigrateArgs -> [String] asPrismaCliArgs migrateArgs = do @@ -65,20 +75,15 @@ asPrismaCliArgs migrateArgs = do -- Because of the --exit-code flag, it changes the exit code behavior -- to signal if the diff is empty or not (Empty: 0, Error: 1, Not empty: 2) migrateDiff :: Path' Abs (Dir ProjectRootDir) -> J.Job -migrateDiff projectDir = do - let serverDir = projectDir serverRootDirInProjectRootDir - let schemaFileFp = SP.toFilePath $ projectDir dbSchemaFileInProjectRootDir - let prismaMigrateDiffCmdArgs = - [ "migrate", - "diff", - "--from-schema-datamodel", - schemaFileFp, - "--to-schema-datasource", - schemaFileFp, - "--exit-code" - ] - - runNodeCommandAsJob serverDir (absPrismaExecutableFp projectDir) prismaMigrateDiffCmdArgs J.Db +migrateDiff projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> + [ "migrate", + "diff", + "--from-schema-datamodel", + SP.fromAbsFile schema, + "--to-schema-datasource", + SP.fromAbsFile schema, + "--exit-code" + ] -- | Checks to see if all migrations are applied to the DB. -- An exit code of 0 means we successfully verified all migrations are applied. @@ -86,36 +91,66 @@ migrateDiff projectDir = do -- or (b) there are pending migrations to apply. -- Therefore, this should be checked **after** a command that ensures connectivity. migrateStatus :: Path' Abs (Dir ProjectRootDir) -> J.Job -migrateStatus projectDir = do - let serverDir = projectDir serverRootDirInProjectRootDir - let schemaFileFp = SP.toFilePath $ projectDir dbSchemaFileInProjectRootDir - let prismaMigrateDiffCmdArgs = - [ "migrate", - "status", - "--schema", - schemaFileFp - ] +migrateStatus projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> + ["migrate", "status", "--schema", SP.fromAbsFile schema] - runNodeCommandAsJob serverDir (absPrismaExecutableFp projectDir) prismaMigrateDiffCmdArgs J.Db +-- | Runs `prisma migrate reset`, which drops the tables (so schemas and data is lost) and then +-- reapplies all the migrations. +reset :: Path' Abs (Dir ProjectRootDir) -> J.Job +reset projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> + -- NOTE(martin): We do "--skip-seed" here because I just think seeding happening automatically on + -- reset is too aggressive / confusing. + ["migrate", "reset", "--schema", SP.fromAbsFile schema, "--skip-generate", "--skip-seed"] + +-- | Runs `prisma db seed`, which executes the seeding script specified in package.json in +-- prisma.seed field. +seed :: Path' Abs (Dir ProjectRootDir) -> String -> J.Job +-- NOTE: Since v 0.3, Prisma doesn't use --schema parameter for `db seed`. +seed projectDir seedName = + runPrismaCommandAsJobWithExtraEnv + J.Db + [(dbSeedNameEnvVarName, seedName)] + projectDir + (const ["db", "seed"]) -- | Runs `prisma studio` - Prisma's db inspector. runStudio :: Path' Abs (Dir ProjectRootDir) -> J.Job -runStudio projectDir = - runNodeCommandAsJob serverDir (absPrismaExecutableFp projectDir) prismaStudioCmdArgs J.Db - where - serverDir = projectDir serverRootDirInProjectRootDir - schemaFile = projectDir dbSchemaFileInProjectRootDir - prismaStudioCmdArgs = ["studio", "--schema", SP.toFilePath schemaFile] +runStudio projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> + ["studio", "--schema", SP.fromAbsFile schema] generatePrismaClient :: Path' Abs (Dir ProjectRootDir) -> (String, String) -> JobType -> J.Job generatePrismaClient projectDir prismaClientOutputDirEnv jobType = - runNodeCommandAsJobWithExtraEnv envVars serverRootDir prismaExecutable prismaGenerateCmdArgs jobType + runPrismaCommandAsJobWithExtraEnv jobType envVars projectDir $ \schema -> + ["generate", "--schema", SP.fromAbsFile schema] where envVars = [prismaClientOutputDirEnv] - serverRootDir = projectDir serverRootDirInProjectRootDir - prismaExecutable = absPrismaExecutableFp projectDir - prismaGenerateCmdArgs = ["generate", "--schema", schemaFile] - schemaFile = SP.fromAbsFile $ projectDir dbSchemaFileInProjectRootDir + +runPrismaCommandAsDbJob :: + Path' Abs (Dir ProjectRootDir) -> + (Path' Abs (File PrismaDbSchema) -> [String]) -> + J.Job +runPrismaCommandAsDbJob projectDir makeCmdArgs = + runPrismaCommandAsJob J.Db projectDir makeCmdArgs + +runPrismaCommandAsJob :: + JobType -> + Path' Abs (Dir ProjectRootDir) -> + (Path' Abs (File PrismaDbSchema) -> [String]) -> + J.Job +runPrismaCommandAsJob jobType projectDir makeCmdArgs = + runPrismaCommandAsJobWithExtraEnv jobType [] projectDir makeCmdArgs + +runPrismaCommandAsJobWithExtraEnv :: + JobType -> + [(String, String)] -> + Path' Abs (Dir ProjectRootDir) -> + (Path' Abs (File PrismaDbSchema) -> [String]) -> + J.Job +runPrismaCommandAsJobWithExtraEnv jobType envVars projectDir makeCmdArgs = + runNodeCommandAsJobWithExtraEnv envVars serverDir (absPrismaExecutableFp projectDir) (makeCmdArgs schemaFile) jobType + where + serverDir = projectDir serverRootDirInProjectRootDir + schemaFile = projectDir dbSchemaFileInProjectRootDir -- | NOTE: The expectation is that `npm install` was already executed -- such that we can use the locally installed package. diff --git a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs index ce16c8efc..30c97a22a 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs @@ -4,6 +4,8 @@ module Wasp.Generator.DbGenerator.Operations doesSchemaMatchDb, writeDbSchemaChecksumToFile, areAllMigrationsAppliedToDb, + dbReset, + dbSeed, ) where @@ -18,7 +20,6 @@ import qualified Path as P import StrongPath (Abs, Dir, File, Path', Rel, ()) import qualified StrongPath as SP import System.Exit (ExitCode (..)) -import Wasp.Common (DbMigrationsDir) import Wasp.Generator.Common (ProjectRootDir) import Wasp.Generator.DbGenerator.Common ( DbSchemaChecksumFile, @@ -38,8 +39,9 @@ import Wasp.Generator.FileDraft.WriteableMonad (WriteableMonad (copyDirectoryRec import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.IO (printJobMsgsUntilExitReceived, readJobMessagesAndPrintThemPrefixed) import qualified Wasp.Generator.WriteFileDrafts as Generator.WriteFileDrafts +import Wasp.Project.Db.Migrations (DbMigrationsDir) import Wasp.Util (checksumFromFilePath, hexToString) -import Wasp.Util.IO (doesFileExist, removeFile) +import Wasp.Util.IO (deleteFileIfExists, doesFileExist) import qualified Wasp.Util.IO as IOUtil -- | Migrates in the generated project context and then copies the migrations dir back @@ -101,10 +103,37 @@ removeDbSchemaChecksumFile :: Path' Abs (Dir ProjectRootDir) -> Path' (Rel ProjectRootDir) (File f) -> IO () -removeDbSchemaChecksumFile genProjectRootDirAbs dbSchemaChecksumInProjectRootDir = removeFile dbSchemaChecksumFp +removeDbSchemaChecksumFile genProjectRootDirAbs dbSchemaChecksumInProjectRootDir = deleteFileIfExists dbSchemaChecksumFp where dbSchemaChecksumFp = genProjectRootDirAbs dbSchemaChecksumInProjectRootDir +-- Resets the database: drops all data and applies all migrations from scratch. +dbReset :: + Path' Abs (Dir ProjectRootDir) -> + IO (Either String ()) +dbReset genProjectDir = do + -- We are doing quite a move here, resetting the whole db, so best to delete the checksum file, + -- which will force Wasp to do a deep check of migrations next time, just to be sure. + removeDbSchemaChecksumFile genProjectDir dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir + chan <- newChan + ((), exitCode) <- + readJobMessagesAndPrintThemPrefixed chan `concurrently` DbJobs.reset genProjectDir chan + return $ case exitCode of + ExitSuccess -> Right () + ExitFailure c -> Left $ "Failed with exit code " <> show c + +dbSeed :: + Path' Abs (Dir ProjectRootDir) -> + String -> + IO (Either String ()) +dbSeed genProjectDir seedName = do + chan <- newChan + ((), exitCode) <- + readJobMessagesAndPrintThemPrefixed chan `concurrently` DbJobs.seed genProjectDir seedName chan + return $ case exitCode of + ExitSuccess -> Right () + ExitFailure c -> Left $ "Failed with exit code " <> show c + generatePrismaClients :: Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) generatePrismaClients projectRootDir = do generateResult <- liftA2 (>>) generatePrismaClientForServer generatePrismaClientForWebApp projectRootDir diff --git a/waspc/src/Wasp/Generator/FileDraft.hs b/waspc/src/Wasp/Generator/FileDraft.hs index 742fb1771..40b8867f8 100644 --- a/waspc/src/Wasp/Generator/FileDraft.hs +++ b/waspc/src/Wasp/Generator/FileDraft.hs @@ -6,6 +6,7 @@ module Wasp.Generator.FileDraft createCopyFileDraftIfExists, createTextFileDraft, createCopyDirFileDraft, + createCopyAndModifyTextFileDraft, ) where @@ -14,6 +15,7 @@ import Data.Text (Text) import StrongPath (Abs, Dir, File, Path', Rel) import qualified StrongPath as SP import Wasp.Generator.Common (ProjectRootDir) +import qualified Wasp.Generator.FileDraft.CopyAndModifyTextFileDraft as CMTextFD import qualified Wasp.Generator.FileDraft.CopyDirFileDraft as CopyDirFD import qualified Wasp.Generator.FileDraft.CopyFileDraft as CopyFD import qualified Wasp.Generator.FileDraft.TemplateFileDraft as TmplFD @@ -33,23 +35,26 @@ data FileDraft | FileDraftCopyFd CopyFD.CopyFileDraft | FileDraftCopyDirFd CopyDirFD.CopyDirFileDraft | FileDraftTextFd TextFD.TextFileDraft - deriving (Show, Eq) + | FileDraftCopyAndModifyTextFd CMTextFD.CopyAndModifyTextFileDraft instance Writeable FileDraft where write dstDir (FileDraftTemplateFd draft) = write dstDir draft write dstDir (FileDraftCopyFd draft) = write dstDir draft write dstDir (FileDraftCopyDirFd draft) = write dstDir draft write dstDir (FileDraftTextFd draft) = write dstDir draft + write dstDir (FileDraftCopyAndModifyTextFd draft) = write dstDir draft getChecksum (FileDraftTemplateFd draft) = getChecksum draft getChecksum (FileDraftCopyFd draft) = getChecksum draft getChecksum (FileDraftCopyDirFd draft) = getChecksum draft getChecksum (FileDraftTextFd draft) = getChecksum draft + getChecksum (FileDraftCopyAndModifyTextFd draft) = getChecksum draft getDstPath (FileDraftTemplateFd draft) = getDstPath draft getDstPath (FileDraftCopyFd draft) = getDstPath draft getDstPath (FileDraftCopyDirFd draft) = getDstPath draft getDstPath (FileDraftTextFd draft) = getDstPath draft + getDstPath (FileDraftCopyAndModifyTextFd draft) = getDstPath draft createTemplateFileDraft :: Path' (Rel ProjectRootDir) (File a) -> @@ -73,6 +78,17 @@ createCopyFileDraft dstPath srcPath = CopyFD._failIfSrcDoesNotExist = True } +createCopyAndModifyTextFileDraft :: + Path' (Rel ProjectRootDir) (File a) -> Path' Abs (File b) -> (Text -> Text) -> FileDraft +createCopyAndModifyTextFileDraft dstPath srcPath modify = + FileDraftCopyAndModifyTextFd $ + CMTextFD.CopyAndModifyTextFileDraft + { CMTextFD._dstPath = SP.castFile dstPath, + CMTextFD._srcPath = SP.castFile srcPath, + CMTextFD._modify = modify, + CMTextFD._failIfSrcDoesNotExist = True + } + createCopyFileDraftIfExists :: Path' (Rel ProjectRootDir) (File a) -> Path' Abs (File b) -> FileDraft createCopyFileDraftIfExists dstPath srcPath = FileDraftCopyFd $ diff --git a/waspc/src/Wasp/Generator/FileDraft/CopyAndModifyTextFileDraft.hs b/waspc/src/Wasp/Generator/FileDraft/CopyAndModifyTextFileDraft.hs new file mode 100644 index 000000000..d1a4110d6 --- /dev/null +++ b/waspc/src/Wasp/Generator/FileDraft/CopyAndModifyTextFileDraft.hs @@ -0,0 +1,54 @@ +module Wasp.Generator.FileDraft.CopyAndModifyTextFileDraft + ( CopyAndModifyTextFileDraft (..), + ) +where + +import Control.Monad (when) +import qualified Data.Text as T +import qualified Data.Text.IO as T.IO +import StrongPath (Abs, File', Path', Rel, ()) +import qualified StrongPath as SP +import System.IO.Error (doesNotExistErrorType, mkIOError) +import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.FileDraft.Writeable +import Wasp.Generator.FileDraft.WriteableMonad +import Wasp.Util (checksumFromText) + +-- | File draft based on existing text file + some modifications. +data CopyAndModifyTextFileDraft = CopyAndModifyTextFileDraft + { -- | Path where the file will be copied to. + _dstPath :: !(Path' (Rel ProjectRootDir) File'), + -- | Absolute path of source file to copy. + _srcPath :: !(Path' Abs File'), + _failIfSrcDoesNotExist :: !Bool, + _modify :: !(T.Text -> T.Text) + } + +instance Writeable CopyAndModifyTextFileDraft where + write absDstDirPath draft = do + srcFileExists <- doesFileExist (SP.fromAbsFile $ _srcPath draft) + if srcFileExists + then do + createDirectoryIfMissing True (SP.fromAbsDir $ SP.parent absDraftDstPath) + readFileAsText (SP.fromAbsFile $ _srcPath draft) + >>= writeFileFromText (SP.fromAbsFile absDraftDstPath) . _modify draft + else + when + (_failIfSrcDoesNotExist draft) + ( throwIO $ + mkIOError + doesNotExistErrorType + "Source file of CopyTextFileDraft does not exist." + Nothing + (Just $ SP.fromAbsFile $ _srcPath draft) + ) + where + absDraftDstPath = absDstDirPath _dstPath draft + + -- NOTE: We are reading file here, but then also again later in the "write", so + -- we are reading file at least twice! Any way to avoid it? + -- Idea: We could cache it in an mvar in the CopyTextFileDraft. + getChecksum draft = + checksumFromText . _modify draft <$> T.IO.readFile (SP.fromAbsFile $ _srcPath draft) + + getDstPath draft = Left $ _dstPath draft diff --git a/waspc/src/Wasp/Generator/FileDraft/CopyFileDraft.hs b/waspc/src/Wasp/Generator/FileDraft/CopyFileDraft.hs index 561245bd2..ad95855ef 100644 --- a/waspc/src/Wasp/Generator/FileDraft/CopyFileDraft.hs +++ b/waspc/src/Wasp/Generator/FileDraft/CopyFileDraft.hs @@ -19,7 +19,7 @@ data CopyFileDraft = CopyFileDraft _dstPath :: !(Path' (Rel ProjectRootDir) File'), -- | Absolute path of source file to copy. _srcPath :: !(Path' Abs File'), - _failIfSrcDoesNotExist :: Bool + _failIfSrcDoesNotExist :: !Bool } deriving (Show, Eq) diff --git a/waspc/src/Wasp/Generator/FileDraft/WriteableMonad.hs b/waspc/src/Wasp/Generator/FileDraft/WriteableMonad.hs index af5f03dc8..406289639 100644 --- a/waspc/src/Wasp/Generator/FileDraft/WriteableMonad.hs +++ b/waspc/src/Wasp/Generator/FileDraft/WriteableMonad.hs @@ -54,6 +54,8 @@ class (MonadIO m) => WriteableMonad m where writeFileFromText :: FilePath -> Text -> m () + readFileAsText :: FilePath -> m Text + getTemplateFileAbsPath :: -- | Template file path. Path' (Rel Templates.TemplatesDir) (File a) -> @@ -94,6 +96,7 @@ instance WriteableMonad IO where doesFileExist = System.Directory.doesFileExist doesDirectoryExist = System.Directory.doesDirectoryExist writeFileFromText = Data.Text.IO.writeFile + readFileAsText = Data.Text.IO.readFile getTemplateFileAbsPath = Templates.getTemplateFileAbsPath getTemplatesDirAbsPath = Templates.getTemplatesDirAbsPath compileAndRenderTemplate = Templates.compileAndRenderTemplate diff --git a/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs b/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs index 4c2a8b629..e0fbe83fa 100644 --- a/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs +++ b/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs @@ -169,12 +169,30 @@ getJobMessageOutput jm = makeJobMessagePrefix :: J.JobMessage -> T.Text makeJobMessagePrefix jobMsg = - case J._jobType jobMsg of - J.Server -> T.pack $ Term.applyStyles [Term.Magenta] "Server" - J.WebApp -> T.pack $ Term.applyStyles [Term.Cyan] "Web app" - J.Db -> T.pack $ Term.applyStyles [Term.Blue] "Db" - <> ( if getJobMessageOutHandle jobMsg == stderr - then T.pack $ Term.applyStyles [Term.Yellow] "(stderr)" - else "" - ) - <> ": " + (T.pack . buildPrefix . concat) + [ [("[", jobStyles)], + [(jobName, jobStyles)], + styledFlags, + [("]", jobStyles)] + ] + where + buildPrefix :: [StyledText] -> String + buildPrefix styledTexts = + concatMap styledTextToTermText styledTexts <> replicate paddingLength ' ' + where + numVisibleChars = length $ concatMap fst styledTexts + paddingLength = max 0 (minPrefixLength - numVisibleChars) + styledTextToTermText (text, styles) = Term.applyStyles styles text + -- NOTE: Adjust this number if you expect longer prefixes! + minPrefixLength = 10 + + (jobName, jobStyles) = case J._jobType jobMsg of + J.Server -> ("Server", [Term.Magenta]) + J.WebApp -> ("Client", [Term.Cyan]) + J.Db -> ("Db", [Term.Blue]) + + styledFlags :: [StyledText] + styledFlags = + [("!", [Term.Red]) | getJobMessageOutHandle jobMsg == stderr] + +type StyledText = (String, [Term.Style]) diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index 2f036e1ce..2f88e5570 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -1,4 +1,7 @@ {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} + +{-# HLINT ignore "Replace case with maybe" #-} module Wasp.Generator.ServerGenerator ( genServer, @@ -10,10 +13,12 @@ where import Data.Aeson (object, (.=)) import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy.UTF8 as ByteStringLazyUTF8 import Data.Maybe ( fromJust, fromMaybe, isJust, + maybeToList, ) import StrongPath ( Dir, @@ -27,7 +32,7 @@ import StrongPath relfile, (), ) -import Wasp.AppSpec (AppSpec) +import Wasp.AppSpec (AppSpec, getApis) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.App.Auth @@ -36,6 +41,7 @@ import qualified Wasp.AppSpec.App.Server as AS.App.Server import qualified Wasp.AppSpec.Entity as AS.Entity import Wasp.AppSpec.Util (isPgBossJobExecutorUsed) import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.Env (envVarsToDotEnvContent) import Wasp.Generator.Common ( ServerRootDir, latestMajorNodeVersion, @@ -44,18 +50,22 @@ import Wasp.Generator.Common prismaVersion, ) import Wasp.Generator.ExternalCodeGenerator (genExternalCodeDir) -import Wasp.Generator.FileDraft (FileDraft, createCopyFileDraft) +import Wasp.Generator.FileDraft (FileDraft, createTextFileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.NpmDependencies as N +import Wasp.Generator.ServerGenerator.ApiRoutesG (genApis) +import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport) import Wasp.Generator.ServerGenerator.AuthG (genAuth) import qualified Wasp.Generator.ServerGenerator.Common as C import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile) -import Wasp.Generator.ServerGenerator.ExternalAuthG (depsRequiredByPassport) +import Wasp.Generator.ServerGenerator.Db.Seed (genDbSeed, getPackageJsonPrismaSeedField) +import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender) import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy) import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobExecutors, genJobs) -import Wasp.Generator.ServerGenerator.JsImport (getJsImportStmtAndIdentifier) +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import Wasp.Generator.ServerGenerator.OperationsG (genOperations) import Wasp.Generator.ServerGenerator.OperationsRoutesG (genOperationsRoutes) +import Wasp.Project.Db (databaseUrlEnvVarName) import Wasp.SemanticVersion (major) import Wasp.Util (toLowerFirst, (<++>)) @@ -78,20 +88,28 @@ genServer spec = <++> genPatches spec <++> genUniversalDir <++> genEnvValidationScript - <++> genExportedTypesDir + <++> genExportedTypesDir spec + <++> genApis spec where genFileCopy = return . C.mkTmplFd genDotEnv :: AppSpec -> Generator [FileDraft] -genDotEnv spec = return $ - case AS.dotEnvServerFile spec of - Just srcFilePath - | not $ AS.isBuild spec -> - [ createCopyFileDraft - (C.serverRootDirInProjectRootDir dotEnvInServerRootDir) - srcFilePath - ] - _ -> [] +-- Don't generate .env if we are building for production, since .env is to be used only for +-- development. +genDotEnv spec | AS.isBuild spec = return [] +genDotEnv spec = + return + [ createTextFileDraft + (C.serverRootDirInProjectRootDir dotEnvInServerRootDir) + (envVarsToDotEnvContent envVars) + ] + where + envVars = waspEnvVars ++ userEnvVars + userEnvVars = AS.devEnvVarsServer spec + waspEnvVars = case AS.devDatabaseUrl spec of + Just url | not isThereCustomDbUrl -> [(databaseUrlEnvVarName, url)] + _ -> [] + isThereCustomDbUrl = any ((== databaseUrlEnvVarName) . fst) userEnvVars dotEnvInServerRootDir :: Path' (Rel ServerRootDir) File' dotEnvInServerRootDir = [relfile|.env|] @@ -112,12 +130,18 @@ genPackageJson spec waspDependencies = do .= ( (if hasEntities then "npm run db-migrate-prod && " else "") ++ "NODE_ENV=production npm run start" ), - "overrides" .= getPackageJsonOverrides + "overrides" .= getPackageJsonOverrides, + "prisma" .= ByteStringLazyUTF8.toString (Aeson.encode $ getPackageJsonPrismaField spec) ] ) where hasEntities = not . null $ AS.getDecls @AS.Entity.Entity spec +getPackageJsonPrismaField :: AppSpec -> Aeson.Value +getPackageJsonPrismaField spec = object $ [] <> seedEntry + where + seedEntry = maybeToList $ Just . ("seed" .=) =<< getPackageJsonPrismaSeedField spec + npmDepsForWasp :: AppSpec -> N.NpmDepsForWasp npmDepsForWasp spec = N.NpmDepsForWasp @@ -135,10 +159,13 @@ npmDepsForWasp spec = ("helmet", "^6.0.0"), ("patch-package", "^6.4.7"), ("uuid", "^9.0.0"), - ("lodash.merge", "^4.6.2") + ("lodash.merge", "^4.6.2"), + ("rate-limiter-flexible", "^2.4.1"), + ("superjson", "^1.12.2") ] ++ depsRequiredByPassport spec - ++ depsRequiredByJobs spec, + ++ depsRequiredByJobs spec + ++ depsRequiredByEmail spec, N.waspDevDependencies = AS.Dependency.fromList [ ("nodemon", "^2.0.19"), @@ -148,6 +175,7 @@ npmDepsForWasp spec = -- in their projects and install these dependencies accordingly. ("typescript", "^4.8.4"), ("@types/express", "^4.17.13"), + ("@types/express-serve-static-core", "^4.17.13"), ("@types/node", "^18.11.9"), ("@tsconfig/node" ++ show (major latestMajorNodeVersion), "^1.0.1") ] @@ -185,6 +213,8 @@ genSrcDir spec = <++> genOperationsRoutes spec <++> genOperations spec <++> genAuth spec + <++> genEmailSender spec + <++> genDbSeed spec where genFileCopy = return . C.mkSrcTmplFd @@ -193,7 +223,7 @@ genDbClient spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl where maybeAuth = AS.App.auth $ snd $ getApp spec - dbClientRelToSrcP = [relfile|dbClient.js|] + dbClientRelToSrcP = [relfile|dbClient.ts|] tmplFile = C.asTmplFile $ [reldir|src|] dbClientRelToSrcP dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile dbClientRelToSrcP @@ -214,17 +244,12 @@ genServerJs spec = (C.asServerFile [relfile|src/server.ts|]) ( Just $ object - [ "doesServerSetupFnExist" .= isJust maybeSetupJsFunction, - "serverSetupJsFnImportStatement" .= fromMaybe "" maybeSetupJsFnImportStmt, - "serverSetupJsFnIdentifier" .= fromMaybe "" maybeSetupJsFnImportIdentifier, + [ "setupFn" .= extImportToImportJson relPathToServerSrcDir maybeSetupJsFunction, "isPgBossJobExecutorUsed" .= isPgBossJobExecutorUsed spec ] ) where maybeSetupJsFunction = AS.App.Server.setupFn =<< AS.App.server (snd $ getApp spec) - maybeSetupJsFnImportDetails = getJsImportStmtAndIdentifier relPathToServerSrcDir <$> maybeSetupJsFunction - (maybeSetupJsFnImportStmt, maybeSetupJsFnImportIdentifier) = - (fst <$> maybeSetupJsFnImportDetails, snd <$> maybeSetupJsFnImportDetails) relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) relPathToServerSrcDir = [reldirP|./|] @@ -240,7 +265,8 @@ genRoutesDir spec = ( Just $ object [ "operationsRouteInRootRouter" .= (operationsRouteInRootRouter :: String), - "isAuthEnabled" .= (isAuthEnabled spec :: Bool) + "isAuthEnabled" .= (isAuthEnabled spec :: Bool), + "areThereAnyCustomApiRoutes" .= (not . null $ getApis spec) ] ) ] @@ -329,7 +355,8 @@ getPackageJsonOverrides = map buildOverrideData (designateLastElement overrides) genUniversalDir :: Generator [FileDraft] genUniversalDir = return - [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|] + [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|], + C.mkUniversalTmplFdWithDst [relfile|types.ts|] [relfile|src/universal/types.ts|] ] genEnvValidationScript :: Generator [FileDraft] @@ -339,8 +366,13 @@ genEnvValidationScript = C.mkUniversalTmplFdWithDst [relfile|validators.js|] [relfile|scripts/universal/validators.mjs|] ] -genExportedTypesDir :: Generator [FileDraft] -genExportedTypesDir = +genExportedTypesDir :: AppSpec -> Generator [FileDraft] +genExportedTypesDir spec = return - [ C.mkTmplFd (C.asTmplFile [relfile|src/types/index.ts|]) + [ C.mkTmplFdWithData [relfile|src/types/index.ts|] (Just tmplData) ] + where + tmplData = object ["isExternalAuthEnabled" .= isExternalAuthEnabled, "isEmailAuthEnabled" .= isEmailAuthEnabled] + isExternalAuthEnabled = AS.App.Auth.isExternalAuthEnabled <$> maybeAuth + isEmailAuthEnabled = AS.App.Auth.isEmailAuthEnabled <$> maybeAuth + maybeAuth = AS.App.auth $ snd $ getApp spec diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ApiRoutesG.hs b/waspc/src/Wasp/Generator/ServerGenerator/ApiRoutesG.hs new file mode 100644 index 000000000..4de2197f1 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/ApiRoutesG.hs @@ -0,0 +1,99 @@ +module Wasp.Generator.ServerGenerator.ApiRoutesG + ( genApis, + ) +where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.Char (toLower) +import Data.List (nub) +import Data.Maybe (fromMaybe) +import StrongPath (Dir, File', Path, Path', Posix, Rel, reldirP, relfile) +import qualified StrongPath as SP +import Wasp.AppSpec (AppSpec, getApis) +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.Api as Api +import qualified Wasp.AppSpec.App as App +import qualified Wasp.AppSpec.App.Auth as App.Auth +import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.Generator.Common (ServerRootDir, makeJsonWithEntityData) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (getJsImportStmtAndIdentifier) +import Wasp.Util (toUpperFirst) + +genApis :: AppSpec -> Generator [FileDraft] +genApis spec = + if areThereAnyCustomApiRoutes + then + sequence + [ genApiRoutes spec, + genApiTypes spec + ] + else return [] + where + areThereAnyCustomApiRoutes = not . null $ getApis spec + +genApiRoutes :: AppSpec -> Generator FileDraft +genApiRoutes spec = + return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + apis = map snd $ AS.getApis spec + tmplData = + object + [ "apiRoutes" .= map getTmplData apis, + "isAuthEnabled" .= isAuthEnabledGlobally spec, + "userEntityName" .= maybe "" (AS.refName . App.Auth.userEntity) (App.auth $ snd $ getApp spec) + ] + tmplFile = C.asTmplFile [relfile|src/routes/apis/index.ts|] + dstFile = SP.castRel tmplFile :: Path' (Rel ServerRootDir) File' + + getTmplData :: Api.Api -> Aeson.Value + getTmplData api = + let (jsImportStmt, jsImportIdentifier) = getJsImportStmtAndIdentifier relPathFromApisRoutesToServerSrcDir (Api.fn api) + in object + [ "routeMethod" .= map toLower (show $ Api.method api), + "routePath" .= Api.path api, + "importStatement" .= jsImportStmt, + "importIdentifier" .= jsImportIdentifier, + "entities" .= getApiEntitiesObject api, + "usesAuth" .= isAuthEnabledForApi spec api + ] + where + relPathFromApisRoutesToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) + relPathFromApisRoutesToServerSrcDir = [reldirP|../..|] + +genApiTypes :: AppSpec -> Generator FileDraft +genApiTypes spec = + return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + namedApis = AS.getApis spec + apis = snd <$> namedApis + tmplData = + object + [ "apiRoutes" .= map getTmplData namedApis, + "shouldImportAuthenticatedApi" .= any usesAuth apis, + "shouldImportNonAuthenticatedApi" .= not (all usesAuth apis), + "allEntities" .= nub (concatMap getApiEntitiesObject apis) + ] + usesAuth = fromMaybe (isAuthEnabledGlobally spec) . Api.auth + tmplFile = C.asTmplFile [relfile|src/apis/types.ts|] + dstFile = SP.castRel tmplFile :: Path' (Rel ServerRootDir) File' + + getTmplData :: (String, Api.Api) -> Aeson.Value + getTmplData (name, api) = + object + [ "typeName" .= toUpperFirst name, + "entities" .= getApiEntitiesObject api, + "usesAuth" .= isAuthEnabledForApi spec api + ] + +getApiEntitiesObject :: Api.Api -> [Aeson.Value] +getApiEntitiesObject api = maybe [] (map (makeJsonWithEntityData . AS.refName)) (Api.entities api) + +isAuthEnabledGlobally :: AppSpec -> Bool +isAuthEnabledGlobally = isAuthEnabled + +isAuthEnabledForApi :: AppSpec -> Api.Api -> Bool +isAuthEnabledForApi spec api = fromMaybe (isAuthEnabled spec) (Api.auth api) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs new file mode 100644 index 000000000..e752d9114 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs @@ -0,0 +1,104 @@ +module Wasp.Generator.ServerGenerator.Auth.EmailAuthG + ( genEmailAuth, + ) +where + +import Data.Aeson (object, (.=)) +import Data.Maybe (fromMaybe) +import StrongPath + ( Dir, + File', + Path, + Path', + Posix, + Rel, + reldirP, + relfile, + (), + ) +import qualified StrongPath as SP +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import qualified Wasp.AppSpec.App.Auth.EmailVerification as AS.Auth.EmailVerification +import qualified Wasp.AppSpec.App.Auth.PasswordReset as AS.Auth.PasswordReset +import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender +import Wasp.AppSpec.Util (getRoutePathFromRef) +import Wasp.Generator.AuthProviders (emailAuthProvider) +import qualified Wasp.Generator.AuthProviders.Email as Email +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) +import Wasp.Util ((<++>)) + +genEmailAuth :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] +genEmailAuth spec auth = case emailAuth of + Just emailAuthConfig -> + sequence + [ genEmailAuthConfig spec emailAuthConfig, + genTypes emailAuthConfig + ] + <++> genRoutes + _ -> return [] + where + emailAuth = AS.Auth.email $ AS.Auth.methods auth + +genEmailAuthConfig :: AS.AppSpec -> AS.Auth.EmailAuthConfig -> Generator FileDraft +genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir SP.castRel authIndexFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir authIndexFileInSrcDir + + tmplData = + object + [ "providerId" .= Email.providerId emailAuthProvider, + "displayName" .= Email.displayName emailAuthProvider, + "fromField" .= fromFieldJson, + "emailVerificationClientRoute" .= emailVerificationClientRoute, + "passwordResetClientRoute" .= passwordResetClientRoute, + "getPasswordResetEmailContent" .= getPasswordResetEmailContent, + "getVerificationEmailContent" .= getVerificationEmailContent, + "allowUnverifiedLogin" .= fromMaybe False (AS.Auth.allowUnverifiedLogin emailAuthConfig) + ] + + fromFieldJson = + object + [ "name" .= fromMaybe "" maybeName, + "email" .= email + ] + + fromField = AS.Auth.fromField emailAuthConfig + maybeName = AS.EmailSender.name fromField + email = AS.EmailSender.email fromField + + emailVerificationClientRoute = getRoutePathFromRef spec $ AS.Auth.EmailVerification.clientRoute emailVerification + passwordResetClientRoute = getRoutePathFromRef spec $ AS.Auth.PasswordReset.clientRoute passwordReset + getPasswordResetEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.PasswordReset.getEmailContentFn passwordReset + getVerificationEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.EmailVerification.getEmailContentFn emailVerification + + emailVerification = AS.Auth.emailVerification emailAuthConfig + passwordReset = AS.Auth.passwordReset emailAuthConfig + + relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) + relPathToServerSrcDir = [reldirP|../../../|] + + authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + authIndexFileInSrcDir = [relfile|auth/providers/config/email.ts|] + +genRoutes :: Generator [FileDraft] +genRoutes = + sequence + [ copyTmplFile [relfile|auth/providers/email/signup.ts|], + copyTmplFile [relfile|auth/providers/email/login.ts|], + copyTmplFile [relfile|auth/providers/email/resetPassword.ts|], + copyTmplFile [relfile|auth/providers/email/requestPasswordReset.ts|], + copyTmplFile [relfile|auth/providers/email/verifyEmail.ts|] + ] + where + copyTmplFile = return . C.mkSrcTmplFd + +genTypes :: AS.Auth.EmailAuthConfig -> Generator FileDraft +genTypes _emailAuthConfig = return $ C.mkTmplFdWithData tmplFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir [relfile|auth/providers/email/types.ts|] + tmplData = object [] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs new file mode 100644 index 000000000..0d6fb2d1d --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs @@ -0,0 +1,74 @@ +module Wasp.Generator.ServerGenerator.Auth.LocalAuthG + ( genLocalAuth, + ) +where + +import Data.Aeson (object, (.=)) +import StrongPath + ( File', + Path', + Rel, + reldir, + relfile, + (), + ) +import qualified StrongPath as SP +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import Wasp.Generator.AuthProviders (localAuthProvider) +import qualified Wasp.Generator.AuthProviders.Local as Local +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.Util as Util + +genLocalAuth :: AS.Auth.Auth -> Generator [FileDraft] +genLocalAuth auth + | AS.Auth.isUsernameAndPasswordAuthEnabled auth = + sequence + [ genLoginRoute auth, + genSignupRoute auth, + genLocalAuthConfig + ] + | otherwise = return [] + +genLocalAuthConfig :: Generator FileDraft +genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir SP.castRel authIndexFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir authIndexFileInSrcDir + + tmplData = + object + [ "providerId" .= Local.providerId localAuthProvider, + "displayName" .= Local.displayName localAuthProvider + ] + + authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + authIndexFileInSrcDir = [relfile|auth/providers/config/local.ts|] + +genLoginRoute :: AS.Auth.Auth -> Generator FileDraft +genLoginRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + loginRouteRelToSrc = [relfile|auth/providers/local/login.ts|] + tmplFile = C.asTmplFile $ [reldir|src|] loginRouteRelToSrc + dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile loginRouteRelToSrc + + tmplData = + let userEntityName = AS.refName $ AS.Auth.userEntity auth + in object + [ "userEntityUpper" .= (userEntityName :: String), + "userEntityLower" .= (Util.toLowerFirst userEntityName :: String) + ] + +genSignupRoute :: AS.Auth.Auth -> Generator FileDraft +genSignupRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + signupRouteRelToSrc = [relfile|auth/providers/local/signup.ts|] + tmplFile = C.asTmplFile $ [reldir|src|] signupRouteRelToSrc + dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile signupRouteRelToSrc + + tmplData = + object + [ "userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String) + ] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs new file mode 100644 index 000000000..f74f7a6c6 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs @@ -0,0 +1,153 @@ +module Wasp.Generator.ServerGenerator.Auth.OAuthAuthG + ( genOAuthAuth, + depsRequiredByPassport, + ) +where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.Maybe (fromJust, isJust) +import StrongPath + ( Dir, + File', + Path, + Path', + Posix, + Rel, + reldir, + reldirP, + relfile, + (), + ) +import qualified StrongPath as SP +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import qualified Wasp.AppSpec.App.Dependency as App.Dependency +import Wasp.AppSpec.Valid (doesUserEntityContainField, getApp) +import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) +import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider) +import qualified Wasp.Generator.AuthProviders.OAuth as OAuth +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import Wasp.Generator.ServerGenerator.Common (ServerSrcDir) +import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) +import Wasp.Util ((<++>)) +import qualified Wasp.Util as Util + +genOAuthAuth :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] +genOAuthAuth spec auth + | AS.Auth.isExternalAuthEnabled auth = + genOAuthHelpers spec auth + <++> genOAuthProvider googleAuthProvider (AS.Auth.google . AS.Auth.methods $ auth) + <++> genOAuthProvider gitHubAuthProvider (AS.Auth.gitHub . AS.Auth.methods $ auth) + | otherwise = return [] + +genOAuthHelpers :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] +genOAuthHelpers spec auth = + sequence + [ genCreateRouter spec auth, + genTypes auth, + genDefaults spec, + return $ C.mkSrcTmplFd [relfile|auth/providers/oauth/init.ts|] + ] + +genCreateRouter :: AS.AppSpec -> AS.Auth.Auth -> Generator FileDraft +genCreateRouter spec auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers/oauth/createRouter.ts|] (Just tmplData) + where + tmplData = + object + [ "userEntityUpper" .= (userEntityName :: String), + "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), + "externalAuthEntityLower" .= (Util.toLowerFirst externalAuthEntityName :: String), + "isPasswordOnUserEntity" .= isPasswordOnUserEntity + ] + userEntityName = AS.refName $ AS.Auth.userEntity auth + externalAuthEntityName = maybe "undefined" AS.refName (AS.Auth.externalAuthEntity auth) + isPasswordOnUserEntity = doesUserEntityContainField spec "password" == Just True + +genTypes :: AS.Auth.Auth -> Generator FileDraft +genTypes auth = return $ C.mkTmplFdWithData tmplFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir [relfile|auth/providers/oauth/types.ts|] + tmplData = object ["userEntityName" .= userEntityName] + userEntityName = AS.refName $ AS.Auth.userEntity auth + +genDefaults :: AS.AppSpec -> Generator FileDraft +genDefaults spec = return $ C.mkTmplFdWithData tmplFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir [relfile|auth/providers/oauth/defaults.ts|] + tmplData = object ["isUsernameOnUserEntity" .= isUsernameOnUserEntity] + isUsernameOnUserEntity = doesUserEntityContainField spec "username" == Just True + +genOAuthProvider :: + OAuthAuthProvider -> + Maybe AS.Auth.ExternalAuthConfig -> + Generator [FileDraft] +genOAuthProvider provider maybeUserConfig + | isJust maybeUserConfig = + sequence + [ genOAuthConfig provider maybeUserConfig $ [reldir|auth/providers/config|] providerTsFile + ] + | otherwise = return [] + where + providerTsFile :: Path' (Rel ()) File' + providerTsFile = fromJust $ SP.parseRelFile $ providerId ++ ".ts" + + providerId = OAuth.providerId provider + +-- Used to generate the specific provider config based on the generic oauth.ts file. +-- The config receives everything: auth info, npm packages, user defined imports and env variables. +-- It's all in one config file. +genOAuthConfig :: + OAuthAuthProvider -> + Maybe AS.Auth.ExternalAuthConfig -> + Path' (Rel ServerSrcDir) File' -> + Generator FileDraft +genOAuthConfig provider maybeUserConfig pathToConfigDst = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir [relfile|auth/providers/config/_oauth.ts|] + dstFile = C.serverSrcDirInServerRootDir pathToConfigDst + tmplData = + object + [ "providerId" .= OAuth.providerId provider, + "displayName" .= OAuth.displayName provider, + "npmPackage" .= App.Dependency.name (OAuth.passportDependency provider), + "oAuthConfigProps" .= getJsonForOAuthConfigProps provider, + "configFn" .= extImportToImportJson relPathFromAuthConfigToServerSrcDir maybeConfigFn, + "userFieldsFn" .= extImportToImportJson relPathFromAuthConfigToServerSrcDir maybeGetUserFieldsFn + ] + maybeConfigFn = AS.Auth.configFn =<< maybeUserConfig + maybeGetUserFieldsFn = AS.Auth.getUserFieldsFn =<< maybeUserConfig + + relPathFromAuthConfigToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) + relPathFromAuthConfigToServerSrcDir = [reldirP|../../../|] + +getJsonForOAuthConfigProps :: OAuthAuthProvider -> [Aeson.Value] +getJsonForOAuthConfigProps provider = + [ object + [ "key" .= ("clientID" :: String), + "value" .= ("process.env." ++ OAuth.clientIdEnvVarName provider) + ], + object + [ "key" .= ("clientSecret" :: String), + "value" .= ("process.env." ++ OAuth.clientSecretEnvVarName provider) + ], + object + [ "key" .= ("scope" :: String), + "value" .= OAuth.scopeStr provider + ] + ] + +depsRequiredByPassport :: AppSpec -> [App.Dependency.Dependency] +depsRequiredByPassport spec = + concat + [ [App.Dependency.make ("passport", "0.6.0") | (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True], + [OAuth.passportDependency googleAuthProvider | (AS.App.Auth.isGoogleAuthEnabled <$> maybeAuth) == Just True], + [OAuth.passportDependency gitHubAuthProvider | (AS.App.Auth.isGitHubAuthEnabled <$> maybeAuth) == Just True] + ] + where + maybeAuth = AS.App.auth $ snd $ getApp spec diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index b08a18d8a..3ff347651 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -18,11 +18,17 @@ import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.Auth -import Wasp.AppSpec.Valid (getApp) +import Wasp.AppSpec.Valid (doesUserEntityContainField, getApp) +import Wasp.Generator.AuthProviders (emailAuthProvider, gitHubAuthProvider, googleAuthProvider, localAuthProvider) +import qualified Wasp.Generator.AuthProviders.Email as EmailProvider +import qualified Wasp.Generator.AuthProviders.Local as LocalProvider +import qualified Wasp.Generator.AuthProviders.OAuth as OAuthProvider import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) +import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth) +import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth) +import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth) import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.ExternalAuthG (genPassportAuth) import Wasp.Util ((<++>)) import qualified Wasp.Util as Util @@ -31,18 +37,21 @@ genAuth spec = case maybeAuth of Just auth -> sequence [ genCoreAuth auth, - genAuthMiddleware auth, - -- Auth routes + genAuthMiddleware spec auth, + genFileCopy [relfile|core/auth/validators.ts|], genAuthRoutesIndex auth, - genLoginRoute auth, - genSignupRoute auth, genMeRoute auth, - genUtilsJs auth + genUtils auth, + genProvidersIndex auth, + genFileCopy [relfile|auth/providers/types.ts|] ] - <++> genPassportAuth auth + <++> genLocalAuth auth + <++> genOAuthAuth spec auth + <++> genEmailAuth spec auth Nothing -> return [] where maybeAuth = AS.App.auth $ snd $ getApp spec + genFileCopy = return . C.mkSrcTmplFd -- | Generates core/auth file which contains auth middleware and createUser() function. genCoreAuth :: AS.Auth.Auth -> Generator FileDraft @@ -59,8 +68,8 @@ genCoreAuth auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl "userEntityLower" .= (Util.toLowerFirst userEntityName :: String) ] -genAuthMiddleware :: AS.Auth.Auth -> Generator FileDraft -genAuthMiddleware auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) +genAuthMiddleware :: AS.AppSpec -> AS.Auth.Auth -> Generator FileDraft +genAuthMiddleware spec auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where -- TODO(martin): In prismaMiddleware.js, we assume that 'username' and 'password' are defined in user entity. -- This was promised to us by AppSpec, which has validation checks for this. @@ -74,8 +83,14 @@ genAuthMiddleware auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Jus tmplData = let userEntityName = AS.refName $ AS.Auth.userEntity auth + isPasswordOnUserEntity = doesUserEntityContainField spec "password" == Just True + isUsernameOnUserEntity = doesUserEntityContainField spec "username" == Just True in object - [ "userEntityUpper" .= (userEntityName :: String) + [ "userEntityUpper" .= userEntityName, + "isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth, + "isPasswordOnUserEntity" .= isPasswordOnUserEntity, + "isUsernameOnUserEntity" .= isUsernameOnUserEntity, + "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth ] genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft @@ -84,39 +99,11 @@ genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Ju tmplFile = C.srcDirInServerTemplatesDir SP.castRel authIndexFileInSrcDir dstFile = C.serverSrcDirInServerRootDir authIndexFileInSrcDir tmplData = - object - [ "isExternalAuthEnabled" .= AS.Auth.isExternalAuthEnabled auth - ] + object ["isExternalAuthEnabled" .= AS.Auth.isExternalAuthEnabled auth] authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' authIndexFileInSrcDir = [relfile|routes/auth/index.js|] -genLoginRoute :: AS.Auth.Auth -> Generator FileDraft -genLoginRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - loginRouteRelToSrc = [relfile|routes/auth/login.js|] - tmplFile = C.asTmplFile $ [reldir|src|] loginRouteRelToSrc - dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile loginRouteRelToSrc - - tmplData = - let userEntityName = AS.refName $ AS.Auth.userEntity auth - in object - [ "userEntityUpper" .= (userEntityName :: String), - "userEntityLower" .= (Util.toLowerFirst userEntityName :: String) - ] - -genSignupRoute :: AS.Auth.Auth -> Generator FileDraft -genSignupRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - signupRouteRelToSrc = [relfile|routes/auth/signup.js|] - tmplFile = C.asTmplFile $ [reldir|src|] signupRouteRelToSrc - dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile signupRouteRelToSrc - - tmplData = - object - [ "userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String) - ] - genMeRoute :: AS.Auth.Auth -> Generator FileDraft genMeRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where @@ -124,29 +111,38 @@ genMeRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplD tmplFile = C.asTmplFile $ [reldir|src|] meRouteRelToSrc dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile meRouteRelToSrc - tmplData = - object - [ "userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String) - ] + tmplData = object ["userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String)] -genUtilsJs :: AS.Auth.Auth -> Generator FileDraft -genUtilsJs auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) +genUtils :: AS.Auth.Auth -> Generator FileDraft +genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where userEntityName = AS.refName $ AS.Auth.userEntity auth - externalAuthEntityName = maybe "undefined" AS.refName (AS.Auth.externalAuthEntity auth) tmplFile = C.srcDirInServerTemplatesDir SP.castRel utilsFileInSrcDir dstFile = C.serverSrcDirInServerRootDir utilsFileInSrcDir tmplData = object [ "userEntityUpper" .= (userEntityName :: String), "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), - "externalAuthEntityLower" .= (Util.toLowerFirst externalAuthEntityName :: String), "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth, - "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth + "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth, + "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth ] utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' - utilsFileInSrcDir = [relfile|routes/auth/utils.js|] + utilsFileInSrcDir = [relfile|auth/utils.ts|] getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) + +genProvidersIndex :: AS.Auth.Auth -> Generator FileDraft +genProvidersIndex auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers/index.ts|] (Just tmplData) + where + tmplData = object ["enabledProviderIds" .= (enabledProviderIds :: [String])] + + enabledProviderIds = + concat + [ [OAuthProvider.providerId gitHubAuthProvider | AS.Auth.isGitHubAuthEnabled auth], + [OAuthProvider.providerId googleAuthProvider | AS.Auth.isGoogleAuthEnabled auth], + [LocalProvider.providerId localAuthProvider | AS.Auth.isUsernameAndPasswordAuthEnabled auth], + [EmailProvider.providerId emailAuthProvider | AS.Auth.isEmailAuthEnabled auth] + ] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Common.hs b/waspc/src/Wasp/Generator/ServerGenerator/Common.hs index 4b9bae116..3319bb4bd 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Common.hs @@ -5,7 +5,6 @@ module Wasp.Generator.ServerGenerator.Common mkTmplFd, mkTmplFdWithDstAndData, mkSrcTmplFd, - dotEnvServer, srcDirInServerTemplatesDir, asTmplFile, asTmplSrcFile, @@ -13,6 +12,7 @@ module Wasp.Generator.ServerGenerator.Common asServerSrcFile, toESModulesImportPath, mkUniversalTmplFdWithDst, + mkTmplFdWithData, ServerRootDir, ServerSrcDir, ServerTemplatesDir, @@ -21,11 +21,16 @@ module Wasp.Generator.ServerGenerator.Common where import qualified Data.Aeson as Aeson -import StrongPath (Dir, File', Path', Rel, reldir, relfile, ()) +import StrongPath (Dir, File', Path', Rel, reldir, ()) import qualified StrongPath as SP import System.FilePath (splitExtension) -import Wasp.Common (WaspProjectDir) -import Wasp.Generator.Common (GeneratedSrcDir, ProjectRootDir, ServerRootDir, UniversalTemplatesDir, universalTemplatesDirInTemplatesDir) +import Wasp.Generator.Common + ( GeneratedSrcDir, + ProjectRootDir, + ServerRootDir, + UniversalTemplatesDir, + universalTemplatesDirInTemplatesDir, + ) import Wasp.Generator.FileDraft (FileDraft, createTemplateFileDraft) import Wasp.Generator.Templates (TemplatesDir) @@ -73,6 +78,14 @@ mkSrcTmplFd pathInTemplatesSrcDir = mkTmplFdWithDstAndData srcPath dstPath Nothi serverSrcDirInServerRootDir (SP.castRel pathInTemplatesSrcDir :: Path' (Rel ServerSrcDir) File') +mkTmplFdWithData :: + Path' (Rel ServerTemplatesDir) File' -> + Maybe Aeson.Value -> + FileDraft +mkTmplFdWithData relSrcPath tmplData = mkTmplFdWithDstAndData relSrcPath dstPath tmplData + where + dstPath = SP.castRel relSrcPath :: Path' (Rel ServerRootDir) File' + mkTmplFdWithDstAndData :: Path' (Rel ServerTemplatesDir) File' -> Path' (Rel ServerRootDir) File' -> @@ -101,9 +114,6 @@ serverTemplatesDirInTemplatesDir = [reldir|server|] srcDirInServerTemplatesDir :: Path' (Rel ServerTemplatesDir) (Dir ServerTemplatesSrcDir) srcDirInServerTemplatesDir = [reldir|src|] -dotEnvServer :: Path' (SP.Rel WaspProjectDir) File' -dotEnvServer = [relfile|.env.server|] - -- Converts the real name of the source file (i.e., name on disk) into a name -- that can be used in an ESNext import. -- Specifically, when using the ESNext module system, all source files must be diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs b/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs index 426f85984..dbce32655 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs @@ -9,10 +9,10 @@ import StrongPath (File', Path', Rel, relfile, ()) import qualified StrongPath as SP import Wasp.AppSpec (AppSpec) import Wasp.AppSpec.Valid (isAuthEnabled) -import Wasp.Generator.DbGenerator.Common (databaseUrlEnvVar) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Project.Db (databaseUrlEnvVarName) genConfigFile :: AppSpec -> Generator FileDraft genConfigFile spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) @@ -22,7 +22,7 @@ genConfigFile spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tm tmplData = object [ "isAuthEnabled" .= isAuthEnabled spec, - "databaseUrlEnvVar" .= databaseUrlEnvVar + "databaseUrlEnvVarName" .= databaseUrlEnvVarName ] configFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Db/Seed.hs b/waspc/src/Wasp/Generator/ServerGenerator/Db/Seed.hs new file mode 100644 index 000000000..90d5cafc5 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/Db/Seed.hs @@ -0,0 +1,66 @@ +module Wasp.Generator.ServerGenerator.Db.Seed + ( genDbSeed, + getPackageJsonPrismaSeedField, + dbSeedNameEnvVarName, + ) +where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.Functor ((<&>)) +import Data.Maybe (maybeToList) +import StrongPath (Dir, File, Path, Path', Posix, Rel, reldirP, relfile, ()) +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Db as AS.Db +import Wasp.AppSpec.ExtImport (ExtImport) +import Wasp.AppSpec.Valid (getApp) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) + +genDbSeed :: AppSpec -> Generator [FileDraft] +genDbSeed spec = + return $ + dbSeedTypesFd : + maybeToList dbSeedFd + where + dbSeedTypesFd = C.mkSrcTmplFd [relfile|dbSeed/types.ts|] + dbSeedFd = + dbSeedsToTemplateData (getDbSeeds spec) <&> \tmplData -> + C.mkTmplFdWithData + (C.srcDirInServerTemplatesDir dbSeedScriptInServerTmplSrcDir) + (Just tmplData) + +dbSeedScriptInServerTmplSrcDir :: Path' (Rel C.ServerTemplatesSrcDir) (File ()) +dbSeedScriptInServerTmplSrcDir = [relfile|dbSeed.ts|] + +pathFromDbSeedScriptToServerSrc :: Path Posix (Rel ()) (Dir C.ServerSrcDir) +pathFromDbSeedScriptToServerSrc = [reldirP|./|] + +getPackageJsonPrismaSeedField :: AppSpec -> Maybe String +getPackageJsonPrismaSeedField spec = + case getDbSeeds spec of + Just (_ : _) -> Just "npm run db-seed" + _ -> Nothing + +getDbSeeds :: AppSpec -> Maybe [ExtImport] +getDbSeeds spec = AS.Db.seeds =<< AS.App.db (snd $ getApp spec) + +dbSeedsToTemplateData :: Maybe [ExtImport] -> Maybe Aeson.Value +dbSeedsToTemplateData Nothing = Nothing +dbSeedsToTemplateData (Just []) = Nothing +dbSeedsToTemplateData (Just seeds) = + Just $ + object + [ "dbSeeds" .= (dbSeedToTemplateData <$> seeds), + "dbSeedNameEnvVarName" .= dbSeedNameEnvVarName + ] + where + dbSeedToTemplateData :: ExtImport -> Aeson.Value + dbSeedToTemplateData extImport = + extImportToImportJson pathFromDbSeedScriptToServerSrc (Just extImport) + +dbSeedNameEnvVarName :: String +dbSeedNameEnvVarName = "WASP_DB_SEED_NAME" diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs new file mode 100644 index 000000000..afcff7682 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs @@ -0,0 +1,69 @@ +module Wasp.Generator.ServerGenerator.EmailSender.Providers + ( smtp, + sendGrid, + mailgun, + providersDirInServerSrc, + EmailSenderProvider (..), + ) +where + +import StrongPath (Dir, File', Path', Rel, reldir, relfile) +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.SemanticVersion as SV + +data EmailSenderProvider = EmailSenderProvider + { npmDependency :: AS.Dependency.Dependency, + setupFnFile :: Path' (Rel ProvidersDir) File', + -- We have to use explicit boolean keys in templates (e.g. "isSMTPProviderEnabled") so each + -- provider provides its own key which we pass to the template. + isEnabledKey :: String + } + deriving (Show, Eq) + +data ProvidersDir + +smtp :: EmailSenderProvider +smtp = + EmailSenderProvider + { npmDependency = nodeMailerDependency, + setupFnFile = [relfile|smtp.ts|], + isEnabledKey = "isSmtpProviderUsed" + } + where + nodeMailerVersionRange :: SV.Range + nodeMailerVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 6 9 1)] + + nodeMailerDependency :: AS.Dependency.Dependency + nodeMailerDependency = AS.Dependency.make ("nodemailer", show nodeMailerVersionRange) + +sendGrid :: EmailSenderProvider +sendGrid = + EmailSenderProvider + { npmDependency = sendGridDependency, + setupFnFile = [relfile|sendgrid.ts|], + isEnabledKey = "isSendGridProviderUsed" + } + where + sendGridVersionRange :: SV.Range + sendGridVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 7 7 0)] + + sendGridDependency :: AS.Dependency.Dependency + sendGridDependency = AS.Dependency.make ("@sendgrid/mail", show sendGridVersionRange) + +mailgun :: EmailSenderProvider +mailgun = + EmailSenderProvider + { npmDependency = mailgunDependency, + setupFnFile = [relfile|mailgun.ts|], + isEnabledKey = "isMailgunProviderUsed" + } + where + mailgunVersionRange :: SV.Range + mailgunVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 0 5 1)] + + mailgunDependency :: AS.Dependency.Dependency + mailgunDependency = AS.Dependency.make ("ts-mailgun", show mailgunVersionRange) + +providersDirInServerSrc :: Path' (Rel C.ServerTemplatesSrcDir) (Dir ProvidersDir) +providersDirInServerSrc = [reldir|email/core/providers|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs new file mode 100644 index 000000000..a5d12e175 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs @@ -0,0 +1,114 @@ +module Wasp.Generator.ServerGenerator.EmailSenderG where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.Maybe (fromMaybe, isJust, maybeToList) +import qualified Data.Text +import StrongPath (File', Path', Rel, relfile, ()) +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import Wasp.AppSpec.App.EmailSender (EmailSender) +import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender +import Wasp.AppSpec.Valid (getApp) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.Generator.ServerGenerator.EmailSender.Providers as Providers +import Wasp.Util ((<++>)) + +genEmailSender :: AppSpec -> Generator [FileDraft] +genEmailSender spec = case maybeEmailSender of + Just emailSender -> + sequence + [ genIndex emailSender + ] + <++> genCore emailSender + Nothing -> return [] + where + maybeEmailSender = AS.App.emailSender $ snd $ getApp spec + +genIndex :: EmailSender -> Generator FileDraft +genIndex email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/index.ts|] + tmplData = getEmailProvidersJson email + +genCore :: EmailSender -> Generator [FileDraft] +genCore email = + sequence + [ genCoreIndex email, + genCoreTypes email, + genCoreHelpers email, + copyTmplFile [relfile|email/core/providers/dummy.ts|] + ] + <++> genEmailSenderProviderSetupFn email + +genCoreIndex :: EmailSender -> Generator FileDraft +genCoreIndex email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/index.ts|] + tmplData = getEmailProvidersJson email + +genCoreTypes :: EmailSender -> Generator FileDraft +genCoreTypes email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/types.ts|] + tmplData = + object ["isDefaultFromFieldDefined" .= isDefaultFromFieldDefined] + isDefaultFromFieldDefined = isJust defaultFromField + defaultFromField = AS.EmailSender.defaultFrom email + +genCoreHelpers :: EmailSender -> Generator FileDraft +genCoreHelpers email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/helpers.ts|] + tmplData = + object + [ "defaultFromField" + .= object + [ "email" .= fromMaybe "" maybeEmail, + "name" .= fromMaybe "" maybeName, + "isNameDefined" .= isJust maybeName + ], + "isDefaultFromFieldDefined" .= isDefaultFromFieldDefined + ] + isDefaultFromFieldDefined = isJust defaultFromField + maybeEmail = AS.EmailSender.email <$> defaultFromField + maybeName = defaultFromField >>= AS.EmailSender.name + defaultFromField = AS.EmailSender.defaultFrom email + +genEmailSenderProviderSetupFn :: EmailSender -> Generator [FileDraft] +genEmailSenderProviderSetupFn email = + sequence + [ copyTmplFile tmplPath + ] + where + provider :: Providers.EmailSenderProvider + provider = getEmailSenderProvider email + + tmplPath = Providers.providersDirInServerSrc Providers.setupFnFile provider + +depsRequiredByEmail :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredByEmail spec = maybeToList maybeNpmDepedency + where + maybeProvider :: Maybe Providers.EmailSenderProvider + maybeProvider = getEmailSenderProvider <$> (AS.App.emailSender . snd . getApp $ spec) + maybeNpmDepedency = Providers.npmDependency <$> maybeProvider + +getEmailProvidersJson :: EmailSender -> Aeson.Value +getEmailProvidersJson email = + object [isEnabledKey .= True] + where + provider :: Providers.EmailSenderProvider + provider = getEmailSenderProvider email + isEnabledKey = Data.Text.pack $ Providers.isEnabledKey provider + +getEmailSenderProvider :: EmailSender -> Providers.EmailSenderProvider +getEmailSenderProvider email = case AS.EmailSender.provider email of + AS.EmailSender.SMTP -> Providers.smtp + AS.EmailSender.SendGrid -> Providers.sendGrid + AS.EmailSender.Mailgun -> Providers.mailgun + +copyTmplFile :: Path' (Rel C.ServerTemplatesSrcDir) File' -> Generator FileDraft +copyTmplFile = return . C.mkSrcTmplFd diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ExternalAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/ExternalAuthG.hs deleted file mode 100644 index ab8281aaf..000000000 --- a/waspc/src/Wasp/Generator/ServerGenerator/ExternalAuthG.hs +++ /dev/null @@ -1,162 +0,0 @@ -module Wasp.Generator.ServerGenerator.ExternalAuthG - ( genPassportAuth, - depsRequiredByPassport, - ) -where - -import Data.Aeson (object, (.=)) -import qualified Data.Aeson as Aeson -import Data.Maybe (fromMaybe, isJust) -import StrongPath - ( Dir, - File', - Path, - Path', - Posix, - Rel, - Rel', - reldirP, - relfile, - (), - ) -import qualified StrongPath as SP -import Wasp.AppSpec (AppSpec) -import qualified Wasp.AppSpec.App as AS.App -import qualified Wasp.AppSpec.App.Auth as AS.App.Auth -import qualified Wasp.AppSpec.App.Auth as AS.Auth -import qualified Wasp.AppSpec.App.Dependency as App.Dependency -import Wasp.AppSpec.Valid (getApp) -import Wasp.Generator.FileDraft (FileDraft) -import Wasp.Generator.Monad (Generator) -import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.JsImport (getJsImportStmtAndIdentifier) -import Wasp.Generator.WebAppGenerator.ExternalAuthG (ExternalAuthInfo (..), gitHubAuthInfo, googleAuthInfo, templateFilePathInPassportDir) -import Wasp.Util ((<++>)) - -genPassportAuth :: AS.Auth.Auth -> Generator [FileDraft] -genPassportAuth auth - | AS.Auth.isExternalAuthEnabled auth = - sequence - [ genPassportJs auth, - copyTmplFile [relfile|routes/auth/passport/generic/provider.js|] - ] - <++> genGoogleAuth auth - <++> genGitHubAuth auth - | otherwise = return [] - where - copyTmplFile = return . C.mkSrcTmplFd - -genPassportJs :: AS.Auth.Auth -> Generator FileDraft -genPassportJs auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - tmplFile = C.srcDirInServerTemplatesDir SP.castRel passportFileInSrcDir - dstFile = C.serverSrcDirInServerRootDir passportFileInSrcDir - tmplData = - object - [ "providers" - .= [ buildProviderData - (_slug googleAuthInfo) - (App.Dependency.name googlePassportDependency) - (AS.Auth.isGoogleAuthEnabled auth) - (templateFilePathInPassportDir googleAuthInfo), - buildProviderData - (_slug gitHubAuthInfo) - (App.Dependency.name gitHubPassportDependency) - (AS.Auth.isGitHubAuthEnabled auth) - (templateFilePathInPassportDir gitHubAuthInfo) - ] - ] - - buildProviderData :: String -> String -> Bool -> Path' Rel' File' -> Aeson.Value - buildProviderData slug npmPackage isEnabled passportTemplateFP = - object - [ "slug" .= slug, - "npmPackage" .= npmPackage, - "isEnabled" .= isEnabled, - "passportImportPath" .= ("./" ++ SP.toFilePath passportTemplateFP) - ] - - passportFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' - passportFileInSrcDir = [relfile|routes/auth/passport/passport.js|] - -genGoogleAuth :: AS.Auth.Auth -> Generator [FileDraft] -genGoogleAuth auth - | AS.Auth.isGoogleAuthEnabled auth = - sequence - [ return $ C.mkSrcTmplFd $ _passportTemplateFilePath googleAuthInfo, - return $ C.mkSrcTmplFd [relfile|routes/auth/passport/google/defaults.js|], - return $ - mkAuthConfigFd - [relfile|routes/auth/passport/generic/configMapping.js|] - [relfile|routes/auth/passport/google/configMapping.js|] - (Just configTmplData) - ] - | otherwise = return [] - where - configTmplData = getTmplDataForAuthMethodConfig auth AS.Auth.google - -genGitHubAuth :: AS.Auth.Auth -> Generator [FileDraft] -genGitHubAuth auth - | AS.Auth.isGitHubAuthEnabled auth = - sequence - [ return $ C.mkSrcTmplFd $ _passportTemplateFilePath gitHubAuthInfo, - return $ C.mkSrcTmplFd [relfile|routes/auth/passport/github/defaults.js|], - return $ - mkAuthConfigFd - [relfile|routes/auth/passport/generic/configMapping.js|] - [relfile|routes/auth/passport/github/configMapping.js|] - (Just configTmplData) - ] - | otherwise = return [] - where - configTmplData = getTmplDataForAuthMethodConfig auth AS.Auth.gitHub - -mkAuthConfigFd :: - Path' (Rel C.ServerTemplatesSrcDir) File' -> - Path' (Rel C.ServerSrcDir) File' -> - Maybe Aeson.Value -> - FileDraft -mkAuthConfigFd pathInTemplatesSrcDir pathInGenProjectSrcDir tmplData = - C.mkTmplFdWithDstAndData srcPath dstPath tmplData - where - srcPath = C.srcDirInServerTemplatesDir pathInTemplatesSrcDir - dstPath = C.serverSrcDirInServerRootDir pathInGenProjectSrcDir - -getTmplDataForAuthMethodConfig :: AS.Auth.Auth -> (AS.Auth.AuthMethods -> Maybe AS.Auth.ExternalAuthConfig) -> Aeson.Value -getTmplDataForAuthMethodConfig auth authMethod = - object - [ "doesConfigFnExist" .= isJust maybeConfigFn, - "configFnImportStatement" .= fromMaybe "" maybeConfigFnImportStmt, - "configFnIdentifier" .= fromMaybe "" maybeConfigFnImportIdentifier, - "doesGetUserFieldsFnExist" .= isJust maybeGetUserFieldsFn, - "getUserFieldsFnImportStatement" .= fromMaybe "" maybeOnSignInFnImportStmt, - "getUserFieldsFnIdentifier" .= fromMaybe "" maybeOnSignInFnImportIdentifier - ] - where - getJsImportStmtAndIdentifier' = getJsImportStmtAndIdentifier relPathFromAuthConfigToServerSrcDir - maybeConfigFn = AS.Auth.configFn =<< authMethod (AS.Auth.methods auth) - maybeConfigFnImportDetails = getJsImportStmtAndIdentifier' <$> maybeConfigFn - (maybeConfigFnImportStmt, maybeConfigFnImportIdentifier) = (fst <$> maybeConfigFnImportDetails, snd <$> maybeConfigFnImportDetails) - - maybeGetUserFieldsFn = AS.Auth.getUserFieldsFn =<< authMethod (AS.Auth.methods auth) - maybeOnSignInFnImportDetails = getJsImportStmtAndIdentifier' <$> maybeGetUserFieldsFn - (maybeOnSignInFnImportStmt, maybeOnSignInFnImportIdentifier) = (fst <$> maybeOnSignInFnImportDetails, snd <$> maybeOnSignInFnImportDetails) - - relPathFromAuthConfigToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) - relPathFromAuthConfigToServerSrcDir = [reldirP|../../../../|] - -depsRequiredByPassport :: AppSpec -> [App.Dependency.Dependency] -depsRequiredByPassport spec = - concat - [ [App.Dependency.make ("passport", "0.6.0") | (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True], - [googlePassportDependency | (AS.App.Auth.isGoogleAuthEnabled <$> maybeAuth) == Just True], - [gitHubPassportDependency | (AS.App.Auth.isGitHubAuthEnabled <$> maybeAuth) == Just True] - ] - where - maybeAuth = AS.App.auth $ snd $ getApp spec - -googlePassportDependency :: App.Dependency.Dependency -googlePassportDependency = App.Dependency.make ("passport-google-oauth20", "2.0.0") - -gitHubPassportDependency :: App.Dependency.Dependency -gitHubPassportDependency = App.Dependency.make ("passport-github2", "0.1.12") diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs index 3413c4c64..f6f1b0484 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs @@ -136,7 +136,7 @@ jobsDirInServerRootDir = SP.castRel jobsDirInServerTemplatesDir -- NOTE: Our pg-boss related documentation references this version in URLs. -- Please update the docs when this changes (until we solve: https://github.com/wasp-lang/wasp/issues/596). pgBossVersionRange :: SV.Range -pgBossVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 8 0 0)] +pgBossVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 8 4 2)] pgBossDependency :: AS.Dependency.Dependency pgBossDependency = AS.Dependency.make ("pg-boss", show pgBossVersionRange) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs index 4c348a0bc..b77c9519c 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs @@ -1,5 +1,6 @@ module Wasp.Generator.ServerGenerator.JsImport where +import qualified Data.Aeson as Aeson import Data.Maybe (fromJust) import StrongPath (Dir, Path, Posix, Rel) import qualified StrongPath as SP @@ -14,6 +15,14 @@ import Wasp.JsImport ) import qualified Wasp.JsImport as JI +extImportToImportJson :: + Path Posix (Rel importLocation) (Dir ServerSrcDir) -> + Maybe EI.ExtImport -> + Aeson.Value +extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImportToImportJson jsImport + where + jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport + getJsImportStmtAndIdentifier :: Path Posix (Rel importLocation) (Dir ServerSrcDir) -> EI.ExtImport -> diff --git a/waspc/src/Wasp/Generator/ServerGenerator/OperationsG.hs b/waspc/src/Wasp/Generator/ServerGenerator/OperationsG.hs index 892ce5f27..d0b4e1ebb 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/OperationsG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/OperationsG.hs @@ -25,7 +25,7 @@ import Wasp.Generator.Common (ServerRootDir, makeJsonWithEntityData) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.JsImport (getJsImportStmtAndIdentifier) +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import Wasp.Util (toUpperFirst, (<++>)) genOperations :: AppSpec -> Generator [FileDraft] @@ -66,7 +66,7 @@ genQuery :: (String, AS.Query.Query) -> Generator FileDraft genQuery (queryName, query) = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where operation = AS.Operation.QueryOp queryName query - tmplFile = C.asTmplFile [relfile|src/queries/_query.js|] + tmplFile = C.asTmplFile [relfile|src/queries/_query.ts|] dstFile = C.serverSrcDirInServerRootDir queryFileInSrcDir queryName tmplData = operationTmplData operation @@ -100,7 +100,7 @@ genAction :: (String, AS.Action.Action) -> Generator FileDraft genAction (actionName, action) = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where operation = AS.Operation.ActionOp actionName action - tmplFile = [relfile|src/actions/_action.js|] + tmplFile = [relfile|src/actions/_action.ts|] dstFile = C.serverSrcDirInServerRootDir actionFileInSrcDir actionName tmplData = operationTmplData operation @@ -108,13 +108,13 @@ queryFileInSrcDir :: String -> Path' (Rel C.ServerSrcDir) File' queryFileInSrcDir queryName = [reldir|queries|] -- TODO: fromJust here could fail if there is some problem with the name, we should handle this. - fromJust (SP.parseRelFile $ queryName ++ ".js") + fromJust (SP.parseRelFile $ queryName ++ ".ts") actionFileInSrcDir :: String -> Path' (Rel C.ServerSrcDir) File' actionFileInSrcDir actionName = [reldir|actions|] -- TODO: fromJust here could fail if there is some problem with the name, we should handle this. - fromJust (SP.parseRelFile $ actionName ++ ".js") + fromJust (SP.parseRelFile $ actionName ++ ".ts") operationFileInSrcDir :: AS.Operation.Operation -> Path' (Rel C.ServerSrcDir) File' operationFileInSrcDir (AS.Operation.QueryOp name _) = queryFileInSrcDir name @@ -123,8 +123,8 @@ operationFileInSrcDir (AS.Operation.ActionOp name _) = actionFileInSrcDir name operationTmplData :: AS.Operation.Operation -> Aeson.Value operationTmplData operation = object - [ "jsFnImportStatement" .= importStmt, - "jsFnIdentifier" .= importIdentifier, + [ "jsFn" .= extImportToImportJson relPathFromOperationsDirToServerSrcDir (Just $ AS.Operation.getFn operation), + "operationTypeName" .= toUpperFirst (getName operation), "entities" .= maybe [] @@ -132,7 +132,5 @@ operationTmplData operation = (AS.Operation.getEntities operation) ] where - (importStmt, importIdentifier) = getJsImportStmtAndIdentifier relPathFromOperationsDirToServerSrcDir (AS.Operation.getFn operation) - relPathFromOperationsDirToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) relPathFromOperationsDirToServerSrcDir = [reldirP|../|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs b/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs index 9c333c282..01f19b752 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs @@ -78,7 +78,9 @@ genOperationRoute spec operation tmplFile = return $ C.mkTmplFdWithDstAndData tm operationName = AS.Operation.getName operation - (operationImportStmt, operationImportIdentifier) = getJsImportStmtAndIdentifier $ makeJsImport operationImportPath (JsImportModule operationName) + (operationImportStmt, operationImportIdentifier) = + getJsImportStmtAndIdentifier $ + makeJsImport operationImportPath (JsImportModule operationName) data OperationsRoutesDir diff --git a/waspc/src/Wasp/Generator/Test.hs b/waspc/src/Wasp/Generator/Test.hs new file mode 100644 index 000000000..df3bee54e --- /dev/null +++ b/waspc/src/Wasp/Generator/Test.hs @@ -0,0 +1,22 @@ +module Wasp.Generator.Test + ( testWebApp, + ) +where + +import Control.Concurrent (newChan) +import Control.Concurrent.Async (concurrently) +import StrongPath (Abs, Dir, Path') +import System.Exit (ExitCode (..)) +import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed) +import qualified Wasp.Generator.WebAppGenerator.Test as WebAppTest + +testWebApp :: [String] -> Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) +testWebApp args projectDir = do + chan <- newChan + let testWebAppJob = WebAppTest.testWebApp args projectDir chan + (testExitCode, _) <- + testWebAppJob `concurrently` readJobMessagesAndPrintThemPrefixed chan + case testExitCode of + ExitSuccess -> return $ Right () + ExitFailure code -> return $ Left $ "Tests failed with exit code " ++ show code ++ "." diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 02d84e37c..26c1ba01e 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -15,7 +15,6 @@ import StrongPath Path', Posix, Rel, - reldir, reldirP, relfile, (), @@ -23,11 +22,11 @@ import StrongPath import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App -import qualified Wasp.AppSpec.App.Auth as AS.App.Auth -import Wasp.AppSpec.App.Client as AS.App.Client +import qualified Wasp.AppSpec.App.Client as AS.App.Client import qualified Wasp.AppSpec.App.Dependency as AS.Dependency import qualified Wasp.AppSpec.Entity as AS.Entity -import Wasp.AppSpec.Valid (getApp) +import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.Env (envVarsToDotEnvContent) import Wasp.Generator.Common ( makeJsonWithEntityData, nodeVersionRange, @@ -40,7 +39,6 @@ import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.NpmDependencies as N import Wasp.Generator.WebAppGenerator.AuthG (genAuth) import qualified Wasp.Generator.WebAppGenerator.Common as C -import Wasp.Generator.WebAppGenerator.ExternalAuthG (ExternalAuthInfo (..), gitHubAuthInfo, googleAuthInfo) import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator ( extClientCodeGeneratorStrategy, extSharedCodeGeneratorStrategy, @@ -48,6 +46,7 @@ import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson) import Wasp.Generator.WebAppGenerator.OperationsGenerator (genOperations) import Wasp.Generator.WebAppGenerator.RouterGenerator (genRouter) +import qualified Wasp.SemanticVersion as SV import Wasp.Util ((<++>)) genWebApp :: AppSpec -> Generator [FileDraft] @@ -57,6 +56,9 @@ genWebApp spec = do genFileCopy [relfile|tsconfig.json|], genFileCopy [relfile|tsconfig.node.json|], genFileCopy [relfile|vite.config.ts|], + genFileCopy [relfile|src/test/vitest/setup.ts|], + genFileCopy [relfile|src/test/vitest/helpers.tsx|], + genFileCopy [relfile|src/test/index.ts|], genFileCopy [relfile|netlify.toml|], genPackageJson spec (npmDepsForWasp spec), genNpmrc, @@ -74,15 +76,15 @@ genWebApp spec = do genFileCopy = return . C.mkTmplFd genDotEnv :: AppSpec -> Generator [FileDraft] -genDotEnv spec = return $ - case AS.dotEnvClientFile spec of - Just srcFilePath - | not $ AS.isBuild spec -> - [ createCopyFileDraft - (C.webAppRootDirInProjectRootDir dotEnvInWebAppRootDir) - srcFilePath - ] - _ -> [] +-- Don't generate .env if we are building for production, since .env is to be used only for +-- development. +genDotEnv spec | AS.isBuild spec = return [] +genDotEnv spec = + return + [ createTextFileDraft + (C.webAppRootDirInProjectRootDir dotEnvInWebAppRootDir) + (envVarsToDotEnvContent $ AS.devEnvVarsClient spec) + ] dotEnvInWebAppRootDir :: Path' (Rel C.WebAppRootDir) File' dotEnvInWebAppRootDir = [relfile|.env|] @@ -124,8 +126,10 @@ npmDepsForWasp spec = -- The web app only needs @prisma/client (we're using the server's -- CLI to generate what's necessary, check the description in -- https://github.com/wasp-lang/wasp/pull/962/ for details). - ("@prisma/client", show prismaVersion) + ("@prisma/client", show prismaVersion), + ("superjson", "^1.12.2") ] + ++ depsRequiredForAuth spec ++ depsRequiredByTailwind spec, N.waspDevDependencies = AS.Dependency.fromList @@ -142,8 +146,15 @@ npmDepsForWasp spec = -- when updating Vite or React versions ("@tsconfig/vite-react", "^1.0.1") ] + ++ depsRequiredForTesting } +depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredForAuth spec = + [AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec] + where + versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)] + depsRequiredByTailwind :: AppSpec -> [AS.Dependency.Dependency] depsRequiredByTailwind spec = if G.CF.isTailwindUsed spec @@ -155,6 +166,17 @@ depsRequiredByTailwind spec = ] else [] +depsRequiredForTesting :: [AS.Dependency.Dependency] +depsRequiredForTesting = + AS.Dependency.fromList + [ ("vitest", "^0.29.3"), + ("@vitest/ui", "^0.29.3"), + ("jsdom", "^21.1.1"), + ("@testing-library/react", "^12.1.5"), + ("@testing-library/jest-dom", "^5.16.5"), + ("msw", "^1.1.0") + ] + genGitignore :: Generator FileDraft genGitignore = return $ @@ -168,28 +190,13 @@ genPublicDir spec = do [ genFaviconFd, genManifestFd ] - <++> genSocialLoginIcons maybeAuth where - maybeAuth = AS.App.auth $ snd $ getApp spec genFaviconFd = C.mkTmplFd (C.asTmplFile [relfile|public/favicon.ico|]) genManifestFd = let tmplData = object ["appName" .= (fst (getApp spec) :: String)] tmplFile = C.asTmplFile [relfile|public/manifest.json|] in C.mkTmplFdWithData tmplFile tmplData -genSocialLoginIcons :: Maybe AS.App.Auth.Auth -> Generator [FileDraft] -genSocialLoginIcons maybeAuth = - return $ - [ C.mkTmplFd (C.asTmplFile fp) - | (isEnabled, fp) <- socialIcons, - (isEnabled <$> maybeAuth) == Just True - ] - where - socialIcons = - [ (AS.App.Auth.isGoogleAuthEnabled, [reldir|public/images|] _logoFileName googleAuthInfo), - (AS.App.Auth.isGitHubAuthEnabled, [reldir|public/images|] _logoFileName gitHubAuthInfo) - ] - genIndexHtml :: AppSpec -> Generator FileDraft genIndexHtml spec = return $ @@ -215,10 +222,13 @@ genSrcDir spec = copyTmplFile [relfile|config.js|], copyTmplFile [relfile|queryClient.js|], copyTmplFile [relfile|utils.js|], + copyTmplFile [relfile|types.ts|], copyTmplFile [relfile|vite-env.d.ts|], + -- Generates api.js file which contains token management and configured api (e.g. axios) instance. + copyTmplFile [relfile|api.ts|], + copyTmplFile [relfile|storage.ts|], genRouter spec, - genIndexJs spec, - genApi + genIndexJs spec ] <++> genOperations spec <++> genEntitiesDir spec @@ -236,10 +246,6 @@ genEntitiesDir spec = return [entitiesIndexFileDraft] (Just $ object ["entities" .= allEntities]) allEntities = map (makeJsonWithEntityData . fst) $ AS.getDecls @AS.Entity.Entity spec --- | Generates api.js file which contains token management and configured api (e.g. axios) instance. -genApi :: Generator FileDraft -genApi = return $ C.mkTmplFd (C.asTmplFile [relfile|src/api.js|]) - genIndexJs :: AppSpec -> Generator FileDraft genIndexJs spec = return $ @@ -260,7 +266,8 @@ genIndexJs spec = genUniversalDir :: Generator [FileDraft] genUniversalDir = return - [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|] + [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|], + C.mkUniversalTmplFdWithDst [relfile|types.ts|] [relfile|src/universal/types.ts|] ] genEnvValidationScript :: Generator [FileDraft] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/Common.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/Common.hs new file mode 100644 index 000000000..dabf7f8e0 --- /dev/null +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/Common.hs @@ -0,0 +1,7 @@ +module Wasp.Generator.WebAppGenerator.Auth.Common where + +import Data.Maybe (fromMaybe) +import qualified Wasp.AppSpec.App.Auth as AS.Auth + +getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String +getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/EmailAuthG.hs new file mode 100644 index 000000000..36956e1c2 --- /dev/null +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/EmailAuthG.hs @@ -0,0 +1,73 @@ +module Wasp.Generator.WebAppGenerator.Auth.EmailAuthG + ( genEmailAuth, + ) +where + +import Data.Aeson (object, (.=)) +import StrongPath (relfile) +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import Wasp.Generator.AuthProviders (emailAuthProvider) +import Wasp.Generator.AuthProviders.Email + ( serverLoginUrl, + serverRequestPasswordResetUrl, + serverResetPasswordUrl, + serverSignupUrl, + serverVerifyEmailUrl, + ) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import Wasp.Generator.WebAppGenerator.Common as C +import Wasp.Util ((<++>)) + +genEmailAuth :: AS.Auth.Auth -> Generator [FileDraft] +genEmailAuth auth + | AS.Auth.isEmailAuthEnabled auth = + sequence + [ genIndex + ] + <++> genActions + | otherwise = return [] + +genIndex :: Generator FileDraft +genIndex = return $ C.mkSrcTmplFd [relfile|auth/email/index.ts|] + +genActions :: Generator [FileDraft] +genActions = + sequence + [ genLoginAction, + genSignupAction, + genPasswordResetActions, + genVerifyEmailAction + ] + +genLoginAction :: Generator FileDraft +genLoginAction = + return $ + C.mkTmplFdWithData + [relfile|src/auth/email/actions/login.ts|] + (object ["loginPath" .= serverLoginUrl emailAuthProvider]) + +genSignupAction :: Generator FileDraft +genSignupAction = + return $ + C.mkTmplFdWithData + [relfile|src/auth/email/actions/signup.ts|] + (object ["signupPath" .= serverSignupUrl emailAuthProvider]) + +genPasswordResetActions :: Generator FileDraft +genPasswordResetActions = + return $ + C.mkTmplFdWithData + [relfile|src/auth/email/actions/passwordReset.ts|] + ( object + [ "requestPasswordResetPath" .= serverRequestPasswordResetUrl emailAuthProvider, + "resetPasswordPath" .= serverResetPasswordUrl emailAuthProvider + ] + ) + +genVerifyEmailAction :: Generator FileDraft +genVerifyEmailAction = + return $ + C.mkTmplFdWithData + [relfile|src/auth/email/actions/verifyEmail.ts|] + (object ["verifyEmailPath" .= serverVerifyEmailUrl emailAuthProvider]) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/LocalAuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/LocalAuthG.hs new file mode 100644 index 000000000..12e11419f --- /dev/null +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/LocalAuthG.hs @@ -0,0 +1,37 @@ +module Wasp.Generator.WebAppGenerator.Auth.LocalAuthG + ( genLocalAuth, + ) +where + +import Data.Aeson (object, (.=)) +import StrongPath (relfile) +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import Wasp.Generator.AuthProviders (localAuthProvider) +import Wasp.Generator.AuthProviders.Local (serverLoginUrl, serverSignupUrl) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import Wasp.Generator.WebAppGenerator.Common as C + +genLocalAuth :: AS.Auth.Auth -> Generator [FileDraft] +genLocalAuth = genActions + +genActions :: AS.Auth.Auth -> Generator [FileDraft] +genActions auth + | AS.Auth.isUsernameAndPasswordAuthEnabled auth = + sequence + [ genLocalLoginAction, + genLocalSignupAction + ] + | otherwise = return [] + +-- | Generates file with signup function to be used by Wasp developer. +genLocalSignupAction :: Generator FileDraft +genLocalSignupAction = return $ C.mkTmplFdWithData (C.asTmplFile [relfile|src/auth/signup.js|]) tmplData + where + tmplData = object ["signupPath" .= serverSignupUrl localAuthProvider] + +-- | Generates file with login function to be used by Wasp developer. +genLocalLoginAction :: Generator FileDraft +genLocalLoginAction = return $ C.mkTmplFdWithData (C.asTmplFile [relfile|src/auth/login.js|]) tmplData + where + tmplData = object ["loginPath" .= serverLoginUrl localAuthProvider] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/OAuthAuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/OAuthAuthG.hs new file mode 100644 index 000000000..0a2c6ab90 --- /dev/null +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/OAuthAuthG.hs @@ -0,0 +1,59 @@ +module Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG + ( genOAuthAuth, + ) +where + +import Data.Aeson (object, (.=)) +import StrongPath (File', Path', Rel', reldir, relfile) +import qualified StrongPath as SP +import qualified Wasp.AppSpec.App.Auth as AS.Auth +import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) +import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider) +import qualified Wasp.Generator.AuthProviders.OAuth as OAuth +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import Wasp.Generator.WebAppGenerator.Auth.Common (getOnAuthSucceededRedirectToOrDefault) +import Wasp.Generator.WebAppGenerator.Common as C +import Wasp.Util ((<++>)) + +genOAuthAuth :: AS.Auth.Auth -> Generator [FileDraft] +genOAuthAuth auth + | AS.Auth.isExternalAuthEnabled auth = + genHelpers auth + <++> sequence [genOAuthCodeExchange auth] + | otherwise = return [] + +genHelpers :: AS.Auth.Auth -> Generator [FileDraft] +genHelpers auth = + return $ + concat + [ [gitHubHelpers | AS.Auth.isGitHubAuthEnabled auth], + [googleHelpers | AS.Auth.isGoogleAuthEnabled auth] + ] + where + gitHubHelpers = mkHelpersFd gitHubAuthProvider [relfile|GitHub.jsx|] + googleHelpers = mkHelpersFd googleAuthProvider [relfile|Google.jsx|] + + mkHelpersFd :: OAuthAuthProvider -> Path' Rel' File' -> FileDraft + mkHelpersFd provider helpersFp = + mkTmplFdWithDstAndData + [relfile|src/auth/helpers/Generic.jsx|] + (SP.castRel $ [reldir|src/auth/helpers|] SP. helpersFp) + (Just tmplData) + where + tmplData = + object + [ "signInPath" .= OAuth.serverLoginUrl provider, + "displayName" .= OAuth.displayName provider + ] + +genOAuthCodeExchange :: AS.Auth.Auth -> Generator FileDraft +genOAuthCodeExchange auth = + return $ + C.mkTmplFdWithData + [relfile|src/auth/pages/OAuthCodeExchange.jsx|] + ( object + [ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth, + "onAuthFailedRedirectTo" .= AS.Auth.onAuthFailedRedirectTo auth + ] + ) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs index 1253b3c29..12a810ac1 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs @@ -5,19 +5,22 @@ where import Data.Aeson (object, (.=)) import Data.Aeson.Types (Pair) -import Data.Maybe (fromMaybe) import StrongPath (File', Path', Rel', reldir, relfile, ()) -import qualified StrongPath as SP import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App -import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import qualified Wasp.AppSpec.App.Auth as AS.Auth import Wasp.AppSpec.Valid (getApp) +import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) +import qualified Wasp.Generator.AuthProviders.OAuth as OAuth +import Wasp.Generator.Common (makeJsArrayFromHaskellList) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) +import Wasp.Generator.WebAppGenerator.Auth.Common (getOnAuthSucceededRedirectToOrDefault) +import Wasp.Generator.WebAppGenerator.Auth.EmailAuthG (genEmailAuth) +import Wasp.Generator.WebAppGenerator.Auth.LocalAuthG (genLocalAuth) +import Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG (genOAuthAuth) import Wasp.Generator.WebAppGenerator.Common as C -import Wasp.Generator.WebAppGenerator.ExternalAuthG (ExternalAuthInfo, gitHubAuthInfo, googleAuthInfo) -import qualified Wasp.Generator.WebAppGenerator.ExternalAuthG as ExternalAuthG import Wasp.Util ((<++>)) genAuth :: AppSpec -> Generator [FileDraft] @@ -25,26 +28,19 @@ genAuth spec = case maybeAuth of Just auth -> sequence - [ genSignup, - genLogin, - genLogout, - genUseAuth, + [ genLogout, + genUseAuth auth, genCreateAuthRequiredPage auth, genUserHelpers ] <++> genAuthForms auth + <++> genLocalAuth auth + <++> genOAuthAuth auth + <++> genEmailAuth auth Nothing -> return [] where maybeAuth = AS.App.auth $ snd $ getApp spec --- | Generates file with signup function to be used by Wasp developer. -genSignup :: Generator FileDraft -genSignup = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/signup.js|]) - --- | Generates file with login function to be used by Wasp developer. -genLogin :: Generator FileDraft -genLogin = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/login.js|]) - -- | Generates file with logout function to be used by Wasp developer. genLogout :: Generator FileDraft genLogout = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/logout.js|]) @@ -52,87 +48,67 @@ genLogout = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/logout.js|]) -- | Generates HOC that handles auth for the given page. genCreateAuthRequiredPage :: AS.Auth.Auth -> Generator FileDraft genCreateAuthRequiredPage auth = - compileTmplToSamePath - [relfile|auth/pages/createAuthRequiredPage.jsx|] - ["onAuthFailedRedirectTo" .= AS.Auth.onAuthFailedRedirectTo auth] + return $ + C.mkTmplFdWithData + [relfile|src/auth/pages/createAuthRequiredPage.jsx|] + (object ["onAuthFailedRedirectTo" .= AS.Auth.onAuthFailedRedirectTo auth]) -- | Generates React hook that Wasp developer can use in a component to get -- access to the currently logged in user (and check whether user is logged in -- ot not). -genUseAuth :: Generator FileDraft -genUseAuth = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/useAuth.js|]) +genUseAuth :: AS.Auth.Auth -> Generator FileDraft +genUseAuth auth = return $ C.mkTmplFdWithData [relfile|src/auth/useAuth.ts|] tmplData + where + tmplData = object ["entitiesGetMeDependsOn" .= makeJsArrayFromHaskellList [userEntityName]] + userEntityName = AS.refName $ AS.Auth.userEntity auth genAuthForms :: AS.Auth.Auth -> Generator [FileDraft] genAuthForms auth = sequence - [ genLoginForm auth, - genSignupForm auth + [ genAuthForm auth, + copyTmplFile [relfile|auth/forms/Login.tsx|], + copyTmplFile [relfile|auth/forms/Signup.tsx|], + copyTmplFile [relfile|auth/forms/ResetPassword.tsx|], + copyTmplFile [relfile|auth/forms/ForgotPassword.tsx|], + copyTmplFile [relfile|auth/forms/VerifyEmail.tsx|], + copyTmplFile [relfile|auth/forms/types.ts|], + copyTmplFile [relfile|stitches.config.js|], + copyTmplFile [relfile|auth/forms/SocialIcons.tsx|], + copyTmplFile [relfile|auth/forms/SocialButton.tsx|] ] - <++> genExternalAuth auth - -genLoginForm :: AS.Auth.Auth -> Generator FileDraft -genLoginForm auth = - compileTmplToSamePath - [relfile|auth/forms/Login.jsx|] - ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth] - -genSignupForm :: AS.Auth.Auth -> Generator FileDraft -genSignupForm auth = - compileTmplToSamePath - [relfile|auth/forms/Signup.jsx|] - ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth] - -genExternalAuth :: AS.Auth.Auth -> Generator [FileDraft] -genExternalAuth auth - | AS.App.Auth.isExternalAuthEnabled auth = (:) <$> genOAuthCodeExchange auth <*> genSocialLoginHelpers auth - | otherwise = return [] - -genSocialLoginHelpers :: AS.Auth.Auth -> Generator [FileDraft] -genSocialLoginHelpers auth = - return $ - concat - [ [gitHubHelpers | AS.App.Auth.isGitHubAuthEnabled auth], - [googleHelpers | AS.App.Auth.isGoogleAuthEnabled auth] - ] where - gitHubHelpers = mkHelpersFd gitHubAuthInfo [relfile|GitHub.jsx|] - googleHelpers = mkHelpersFd googleAuthInfo [relfile|Google.jsx|] + copyTmplFile = return . C.mkSrcTmplFd - mkHelpersFd :: ExternalAuthInfo -> Path' Rel' File' -> FileDraft - mkHelpersFd externalAuthInfo helpersFp = - mkTmplFdWithDstAndData - [relfile|src/auth/helpers/Generic.jsx|] - (SP.castRel $ [reldir|src/auth/helpers|] SP. helpersFp) - (Just tmplData) - where - tmplData = - object - [ "signInPath" .= ExternalAuthG.serverLoginUrl externalAuthInfo, - "iconName" .= SP.toFilePath (ExternalAuthG._logoFileName externalAuthInfo), - "displayName" .= ExternalAuthG._displayName externalAuthInfo - ] - -genOAuthCodeExchange :: AS.Auth.Auth -> Generator FileDraft -genOAuthCodeExchange auth = +genAuthForm :: AS.Auth.Auth -> Generator FileDraft +genAuthForm auth = compileTmplToSamePath - [relfile|auth/pages/OAuthCodeExchange.jsx|] + [relfile|auth/forms/Auth.tsx|] [ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth, - "onAuthFailedRedirectTo" .= AS.Auth.onAuthFailedRedirectTo auth + "areBothSocialAndPasswordBasedAuthEnabled" .= areBothSocialAndPasswordBasedAuthEnabled, + "isAnyPasswordBasedAuthEnabled" .= isAnyPasswordBasedAuthEnabled, + "isExternalAuthEnabled" .= AS.Auth.isExternalAuthEnabled auth, + -- Google + "isGoogleAuthEnabled" .= AS.Auth.isGoogleAuthEnabled auth, + "googleSignInPath" .= OAuth.serverLoginUrl googleAuthProvider, + -- GitHub + "isGitHubAuthEnabled" .= AS.Auth.isGitHubAuthEnabled auth, + "gitHubSignInPath" .= OAuth.serverLoginUrl gitHubAuthProvider, + -- Username and password + "isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth, + -- Email + "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth, + "isEmailVerificationRequired" .= AS.Auth.isEmailVerificationRequired auth ] + where + areBothSocialAndPasswordBasedAuthEnabled = AS.Auth.isExternalAuthEnabled auth && isAnyPasswordBasedAuthEnabled + isAnyPasswordBasedAuthEnabled = AS.Auth.isUsernameAndPasswordAuthEnabled auth || AS.Auth.isEmailAuthEnabled auth compileTmplToSamePath :: Path' Rel' File' -> [Pair] -> Generator FileDraft compileTmplToSamePath tmplFileInTmplSrcDir keyValuePairs = return $ - C.mkTmplFdWithDstAndData + C.mkTmplFdWithData (asTmplFile $ [reldir|src|] tmplFileInTmplSrcDir) - targetPath - (Just templateData) - where - targetPath = C.webAppSrcDirInWebAppRootDir asWebAppSrcFile tmplFileInTmplSrcDir - templateData = object keyValuePairs - -getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String -getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) + (object keyValuePairs) genUserHelpers :: Generator FileDraft genUserHelpers = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/helpers/user.ts|]) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs index c130d7011..fd4ba9988 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs @@ -1,7 +1,6 @@ module Wasp.Generator.WebAppGenerator.Common ( webAppRootDirInProjectRootDir, webAppSrcDirInWebAppRootDir, - dotEnvClient, mkSrcTmplFd, mkTmplFd, mkTmplFdWithDst, @@ -13,18 +12,28 @@ module Wasp.Generator.WebAppGenerator.Common asWebAppFile, asWebAppSrcFile, mkUniversalTmplFdWithDst, + serverRootDirFromWebAppRootDir, WebAppRootDir, WebAppSrcDir, WebAppTemplatesDir, WebAppTemplatesSrcDir, + toViteImportPath, ) where import qualified Data.Aeson as Aeson -import StrongPath (Dir, File', Path', Rel, reldir, relfile, ()) +import Data.Maybe (fromJust) +import StrongPath (Dir, File, File', Path, Path', Posix, Rel, reldir, ()) import qualified StrongPath as SP -import Wasp.Common (WaspProjectDir) -import Wasp.Generator.Common (GeneratedSrcDir, ProjectRootDir, UniversalTemplatesDir, WebAppRootDir, universalTemplatesDirInTemplatesDir) +import System.FilePath (splitExtension) +import Wasp.Generator.Common + ( GeneratedSrcDir, + ProjectRootDir, + ServerRootDir, + UniversalTemplatesDir, + WebAppRootDir, + universalTemplatesDirInTemplatesDir, + ) import Wasp.Generator.FileDraft (FileDraft, createTemplateFileDraft) import Wasp.Generator.Templates (TemplatesDir) @@ -36,6 +45,9 @@ data WebAppTemplatesSrcDir instance GeneratedSrcDir WebAppSrcDir +serverRootDirFromWebAppRootDir :: Path' (Rel WebAppRootDir) (Dir ServerRootDir) +serverRootDirFromWebAppRootDir = [reldir|../server|] + asTmplFile :: Path' (Rel d) File' -> Path' (Rel WebAppTemplatesDir) File' asTmplFile = SP.castRel @@ -65,9 +77,6 @@ webAppTemplatesDirInTemplatesDir = [reldir|react-app|] srcDirInWebAppTemplatesDir :: Path' (Rel WebAppTemplatesDir) (Dir WebAppTemplatesSrcDir) srcDirInWebAppTemplatesDir = [reldir|src|] -dotEnvClient :: Path' (SP.Rel WaspProjectDir) File' -dotEnvClient = [relfile|.env.client|] - mkSrcTmplFd :: Path' (Rel WebAppTemplatesSrcDir) File' -> FileDraft mkSrcTmplFd pathInTemplatesSrcDir = mkTmplFdWithDst srcPath dstPath where @@ -102,3 +111,8 @@ mkUniversalTmplFdWithDst relSrcPath relDstPath = (webAppRootDirInProjectRootDir relDstPath) (universalTemplatesDirInTemplatesDir relSrcPath) Nothing + +toViteImportPath :: Path Posix (Rel r) (File f) -> Path Posix (Rel r) (File f) +toViteImportPath = fromJust . SP.parseRelFileP . dropExtension . SP.fromRelFileP + where + dropExtension = fst . splitExtension diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/ExternalAuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/ExternalAuthG.hs deleted file mode 100644 index e6ad8a5fe..000000000 --- a/waspc/src/Wasp/Generator/WebAppGenerator/ExternalAuthG.hs +++ /dev/null @@ -1,53 +0,0 @@ -module Wasp.Generator.WebAppGenerator.ExternalAuthG - ( googleAuthInfo, - gitHubAuthInfo, - frontendLoginUrl, - serverLoginUrl, - serverOauthRedirectHandlerUrl, - templateFilePathInPassportDir, - ExternalAuthInfo (..), - ) -where - -import StrongPath (File', Path', Rel, Rel', relfile, ()) -import qualified StrongPath as SP -import Wasp.Generator.ServerGenerator.Common (ServerTemplatesSrcDir) - -data ExternalAuthInfo = ExternalAuthInfo - { _passportTemplateFilePath :: Path' (Rel ServerTemplatesSrcDir) File', - _logoFileName :: Path' Rel' File', - _displayName :: String, - _slug :: String - } - -googleAuthInfo :: ExternalAuthInfo -googleAuthInfo = - ExternalAuthInfo - { _passportTemplateFilePath = [relfile|routes/auth/passport/google/config.js|], - _logoFileName = [relfile|google-logo-icon.png|], - _displayName = "Google", - _slug = "google" - } - -gitHubAuthInfo :: ExternalAuthInfo -gitHubAuthInfo = - ExternalAuthInfo - { _passportTemplateFilePath = [relfile|routes/auth/passport/github/config.js|], - _logoFileName = [relfile|github-logo-icon.png|], - _displayName = "GitHub", - _slug = "github" - } - -frontendLoginUrl :: ExternalAuthInfo -> String -frontendLoginUrl eai = "/auth/login/" ++ _slug eai - -serverLoginUrl :: ExternalAuthInfo -> String -serverLoginUrl eai = "/auth/external/" ++ _slug eai ++ "/login" - -serverOauthRedirectHandlerUrl :: ExternalAuthInfo -> String -serverOauthRedirectHandlerUrl eai = "/auth/external/" ++ _slug eai ++ "/validateCodeForLogin" - -templateFilePathInPassportDir :: ExternalAuthInfo -> Path' Rel' File' -templateFilePathInPassportDir eai = - (SP.basename . SP.parent $ _passportTemplateFilePath eai) - SP.basename (_passportTemplateFilePath eai) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs index eeb89e231..68332fa7d 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs @@ -6,9 +6,6 @@ import StrongPath (Dir, Path, Posix, Rel) import qualified StrongPath as SP import Wasp.AppSpec.ExtImport (ExtImport) import qualified Wasp.AppSpec.ExtImport as EI -import Wasp.Generator.JsImport - ( jsImportToImportJson, - ) import qualified Wasp.Generator.JsImport as GJI import Wasp.Generator.WebAppGenerator.Common (WebAppSrcDir) import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator (extClientCodeDirInWebAppSrcDir) @@ -23,7 +20,7 @@ extImportToImportJson :: Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> Maybe ExtImport -> Aeson.Value -extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = jsImportToImportJson jsImport +extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImportToImportJson jsImport where jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs index c5abffc21..438f1d60d 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs @@ -9,21 +9,27 @@ import Data.Aeson ( object, (.=), ) -import Data.List (intercalate) +import Data.Aeson.Types (Pair) import Data.Maybe (fromJust) import StrongPath (File', Path', Rel', parseRelFile, reldir, relfile, ()) +import qualified StrongPath as SP import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.Action as AS.Action import qualified Wasp.AppSpec.Operation as AS.Operation import qualified Wasp.AppSpec.Query as AS.Query +import Wasp.Generator.Common (makeJsArrayFromHaskellList) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator as ServerGenerator +import Wasp.Generator.ServerGenerator.Common (serverSrcDirInServerRootDir) +import Wasp.Generator.ServerGenerator.OperationsG (operationFileInSrcDir) import qualified Wasp.Generator.ServerGenerator.OperationsRoutesG as ServerOperationsRoutesG +import Wasp.Generator.WebAppGenerator.Common (serverRootDirFromWebAppRootDir, toViteImportPath) import qualified Wasp.Generator.WebAppGenerator.Common as C import qualified Wasp.Generator.WebAppGenerator.OperationsGenerator.ResourcesG as Resources -import Wasp.Util ((<++>)) +import Wasp.JsImport (JsImportName (JsImportField), getJsImportStmtAndIdentifier, makeJsImport) +import Wasp.Util (toUpperFirst, (<++>)) genOperations :: AppSpec -> Generator [FileDraft] genOperations spec = @@ -31,7 +37,7 @@ genOperations spec = <++> genActions spec <++> Resources.genResources spec <++> return - [ C.mkSrcTmplFd [relfile|operations/index.js|], + [ C.mkSrcTmplFd [relfile|operations/index.ts|], C.mkSrcTmplFd [relfile|operations/updateHandlersMap.js|] ] @@ -57,11 +63,11 @@ genActions spec = genQuery :: AppSpec -> (String, AS.Query.Query) -> Generator FileDraft genQuery _ (queryName, query) = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where - tmplFile = C.asTmplFile [relfile|src/queries/_query.js|] + tmplFile = C.asTmplFile [relfile|src/queries/_query.ts|] dstFile = C.asWebAppFile $ [reldir|src/queries/|] fromJust (getOperationDstFileName operation) tmplData = - object + object $ [ "queryRoute" .= ( ServerGenerator.operationsRouteInRootRouter ++ "/" @@ -69,16 +75,17 @@ genQuery _ (queryName, query) = return $ C.mkTmplFdWithDstAndData tmplFile dstFi ), "entitiesArray" .= makeJsArrayOfEntityNames operation ] + ++ operationTypeData operation operation = AS.Operation.QueryOp queryName query genAction :: AppSpec -> (String, AS.Action.Action) -> Generator FileDraft genAction _ (actionName, action) = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where - tmplFile = C.asTmplFile [relfile|src/actions/_action.js|] + tmplFile = C.asTmplFile [relfile|src/actions/_action.ts|] dstFile = C.asWebAppFile $ [reldir|src/actions/|] fromJust (getOperationDstFileName operation) tmplData = - object + object $ [ "actionRoute" .= ( ServerGenerator.operationsRouteInRootRouter ++ "/" @@ -86,14 +93,40 @@ genAction _ (actionName, action) = return $ C.mkTmplFdWithDstAndData tmplFile ds ), "entitiesArray" .= makeJsArrayOfEntityNames operation ] + ++ operationTypeData operation operation = AS.Operation.ActionOp actionName action +operationTypeData :: AS.Operation.Operation -> [Pair] +operationTypeData operation = tmplData + where + tmplData = + [ "operationTypeImportStmt" .= (operationTypeImportStmt :: String), + "operationTypeName" .= (operationTypeImportIdentifier :: String) + ] + + (operationTypeImportStmt, operationTypeImportIdentifier) = + getJsImportStmtAndIdentifier $ + makeJsImport operationImportPath (JsImportField $ toUpperFirst operationName) + + operationName = AS.Operation.getName operation + + operationImportPath = + toViteImportPath $ + fromJust $ + SP.relFileToPosix serverOperationFileFromWebAppOperationsDir + + serverOperationFileFromWebAppOperationsDir = + webAppRootDirFromWebAppOperationsDir serverOperationFileFromWebAppRootDir + webAppRootDirFromWebAppOperationsDir = [reldir|../..|] + serverOperationFileFromWebAppRootDir = serverRootDirFromWebAppRootDir serverOperationFileInServerRootDir + serverOperationFileInServerRootDir = serverSrcDirInServerRootDir operationFileInSrcDir operation + -- | Generates string that is JS array containing names (as strings) of entities being used by given operation. -- E.g. "['Task', 'Project']" makeJsArrayOfEntityNames :: AS.Operation.Operation -> String -makeJsArrayOfEntityNames operation = "[" ++ intercalate ", " entityStrings ++ "]" +makeJsArrayOfEntityNames operation = makeJsArrayFromHaskellList entityNames where - entityStrings = maybe [] (map $ \x -> "'" ++ AS.refName x ++ "'") (AS.Operation.getEntities operation) + entityNames = maybe [] (map $ \x -> AS.refName x) (AS.Operation.getEntities operation) getOperationDstFileName :: AS.Operation.Operation -> Maybe (Path' Rel' File') -getOperationDstFileName operation = parseRelFile (AS.Operation.getName operation ++ ".js") +getOperationDstFileName operation = parseRelFile (AS.Operation.getName operation ++ ".ts") diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs index f934c0a4e..2c485d49a 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs @@ -20,11 +20,12 @@ import qualified Wasp.AppSpec.ExtImport as AS.ExtImport import qualified Wasp.AppSpec.Page as AS.Page import qualified Wasp.AppSpec.Route as AS.Route import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) +import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider, frontendLoginUrl, serverOauthRedirectHandlerUrl) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import Wasp.Generator.WebAppGenerator.Common (asTmplFile, asWebAppSrcFile) import qualified Wasp.Generator.WebAppGenerator.Common as C -import Wasp.Generator.WebAppGenerator.ExternalAuthG (ExternalAuthInfo (..), frontendLoginUrl, gitHubAuthInfo, googleAuthInfo, serverOauthRedirectHandlerUrl) import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson, extImportToJsImport) import Wasp.JsImport (applyJsImportAlias, getJsImportStmtAndIdentifier) @@ -114,20 +115,20 @@ createRouterTemplateData spec = externalAuthProviders = map (createExternalAuthProviderTemplateData maybeAuth) - [ (AS.App.Auth.isGoogleAuthEnabled, googleAuthInfo), - (AS.App.Auth.isGitHubAuthEnabled, gitHubAuthInfo) + [ (AS.App.Auth.isGoogleAuthEnabled, googleAuthProvider), + (AS.App.Auth.isGitHubAuthEnabled, gitHubAuthProvider) ] maybeAuth = AS.App.auth $ snd $ getApp spec maybeRootComponent = AS.App.Client.rootComponent =<< AS.App.client (snd $ getApp spec) createExternalAuthProviderTemplateData :: Maybe AS.App.Auth.Auth -> - (AS.App.Auth.Auth -> Bool, ExternalAuthInfo) -> + (AS.App.Auth.Auth -> Bool, OAuthAuthProvider) -> ExternalAuthProviderTemplateData -createExternalAuthProviderTemplateData maybeAuth (method, externalAuthInfo) = +createExternalAuthProviderTemplateData maybeAuth (method, provider) = ExternalAuthProviderTemplateData - { _authFrontendUrl = frontendLoginUrl externalAuthInfo, - _authServerOauthRedirectUrl = serverOauthRedirectHandlerUrl externalAuthInfo, + { _authFrontendUrl = frontendLoginUrl provider, + _authServerOauthRedirectUrl = serverOauthRedirectHandlerUrl provider, _authProviderEnabled = (method <$> maybeAuth) == Just True } diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs new file mode 100644 index 000000000..0770fc257 --- /dev/null +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs @@ -0,0 +1,15 @@ +module Wasp.Generator.WebAppGenerator.Test + ( testWebApp, + ) +where + +import StrongPath (Abs, Dir, Path', ()) +import Wasp.Generator.Common (ProjectRootDir) +import qualified Wasp.Generator.Job as J +import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Generator.WebAppGenerator.Common as Common + +testWebApp :: [String] -> Path' Abs (Dir ProjectRootDir) -> J.Job +testWebApp args projectDir = do + let webAppDir = projectDir Common.webAppRootDirInProjectRootDir + runNodeCommandAsJob webAppDir "npx" ("vitest" : args) J.WebApp diff --git a/waspc/src/Wasp/Lib.hs b/waspc/src/Wasp/Lib.hs deleted file mode 100644 index dfd250e5a..000000000 --- a/waspc/src/Wasp/Lib.hs +++ /dev/null @@ -1,201 +0,0 @@ -module Wasp.Lib - ( compile, - Generator.start, - ProjectRootDir, - findWaspFile, - analyzeWaspProject, - compileAndRenderDockerfile, - CompileError, - CompileWarning, - deploy, - ) -where - -import Control.Arrow -import Control.Concurrent (newChan) -import Control.Concurrent.Async (concurrently) -import Control.Monad.Except -import Control.Monad.Extra (whenMaybeM) -import Data.List (find, isSuffixOf) -import Data.List.NonEmpty (toList) -import Data.Maybe (maybeToList) -import Data.Text (Text) -import qualified Data.Text.IO as T.IO -import StrongPath (Abs, Dir, File', Path', Rel, fromAbsDir, reldir, relfile, toFilePath, ()) -import System.Directory (doesDirectoryExist, doesFileExist) -import qualified Wasp.Analyzer as Analyzer -import Wasp.Analyzer.AnalyzeError (getErrorMessageAndCtx) -import qualified Wasp.AppSpec as AS -import Wasp.AppSpec.Valid (validateAppSpec) -import Wasp.Common (DbMigrationsDir, WaspProjectDir, dbMigrationsDirInWaspProjectDir) -import Wasp.CompileOptions (CompileOptions (generatorWarningsFilter), sendMessage) -import qualified Wasp.CompileOptions as CompileOptions -import qualified Wasp.ConfigFile as CF -import qualified Wasp.Data as Data -import Wasp.Error (showCompilerErrorForTerminal) -import qualified Wasp.ExternalCode as ExternalCode -import qualified Wasp.Generator as Generator -import Wasp.Generator.Common (ProjectRootDir) -import qualified Wasp.Generator.ConfigFile as G.CF -import qualified Wasp.Generator.DockerGenerator as DockerGenerator -import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.IO (printJobMsgsUntilExitReceived) -import Wasp.Generator.Job.Process (runNodeCommandAsJob) -import Wasp.Generator.ServerGenerator.Common (dotEnvServer) -import Wasp.Generator.WebAppGenerator.Common (dotEnvClient) -import Wasp.Util (maybeToEither, unlessM) -import qualified Wasp.Util.IO as IOUtil - -type CompileError = String - -type CompileWarning = String - -compile :: - Path' Abs (Dir WaspProjectDir) -> - Path' Abs (Dir ProjectRootDir) -> - CompileOptions -> - IO ([CompileWarning], [CompileError]) -compile waspDir outDir options = do - compileWarningsAndErrors <- - analyzeWaspProject waspDir options >>= \case - Left analyzerErrors -> return ([], analyzerErrors) - Right appSpec -> generateCode appSpec outDir options - dotEnvWarnings <- maybeToList <$> warnIfDotEnvPresent waspDir - return $ (dotEnvWarnings, []) <> compileWarningsAndErrors - -analyzeWaspProject :: - Path' Abs (Dir WaspProjectDir) -> - CompileOptions -> - IO (Either [CompileError] AS.AppSpec) -analyzeWaspProject waspDir options = runExceptT $ do - waspFilePath <- ExceptT $ left pure <$> findWaspFile waspDir - declarations <- ExceptT $ left pure <$> analyzeWaspFileContent waspFilePath - ExceptT $ constructAppSpec waspDir options declarations - -generateCode :: - AS.AppSpec -> - Path' Abs (Dir ProjectRootDir) -> - CompileOptions -> - IO ([CompileError], [CompileWarning]) -generateCode appSpec outDir options = do - (generatorWarnings, generatorErrors) <- Generator.writeWebAppCode appSpec outDir (sendMessage options) - let filteredWarnings = generatorWarningsFilter options generatorWarnings - return (show <$> filteredWarnings, show <$> generatorErrors) - --- | Checks the wasp directory for potential problems, and issues warnings if any are found. -warnIfDotEnvPresent :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe CompileWarning) -warnIfDotEnvPresent waspDir = (warningMessage <$) <$> findDotEnv waspDir - where - warningMessage = "Wasp .env files should be named .env.server or .env.client, depending on their use." - -analyzeWaspFileContent :: Path' Abs File' -> IO (Either CompileError [AS.Decl]) -analyzeWaspFileContent waspFilePath = do - waspFileContent <- IOUtil.readFile waspFilePath - let declsOrAnalyzeError = Analyzer.analyze waspFileContent - return $ - left - (showCompilerErrorForTerminal (waspFilePath, waspFileContent) . getErrorMessageAndCtx) - declsOrAnalyzeError - -constructAppSpec :: - Path' Abs (Dir WaspProjectDir) -> - CompileOptions -> - [AS.Decl] -> - IO (Either [CompileError] AS.AppSpec) -constructAppSpec waspDir options decls = do - externalServerCodeFiles <- - ExternalCode.readFiles (CompileOptions.externalServerCodeDirPath options) - externalClientCodeFiles <- - ExternalCode.readFiles (CompileOptions.externalClientCodeDirPath options) - externalSharedCodeFiles <- - ExternalCode.readFiles (CompileOptions.externalSharedCodeDirPath options) - maybeDotEnvServerFile <- findDotEnvServer waspDir - maybeDotEnvClientFile <- findDotEnvClient waspDir - maybeMigrationsDir <- findMigrationsDir waspDir - maybeUserDockerfileContents <- loadUserDockerfileContents waspDir - configFiles <- CF.discoverConfigFiles waspDir G.CF.configFileRelocationMap - let appSpec = - AS.AppSpec - { AS.decls = decls, - AS.waspProjectDir = waspDir, - AS.externalClientFiles = externalClientCodeFiles, - AS.externalServerFiles = externalServerCodeFiles, - AS.externalSharedFiles = externalSharedCodeFiles, - AS.migrationsDir = maybeMigrationsDir, - AS.dotEnvServerFile = maybeDotEnvServerFile, - AS.dotEnvClientFile = maybeDotEnvClientFile, - AS.isBuild = CompileOptions.isBuild options, - AS.userDockerfileContents = maybeUserDockerfileContents, - AS.configFiles = configFiles - } - return $ case validateAppSpec appSpec of - [] -> Right appSpec - validationErrors -> Left $ map show validationErrors - -findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Either String (Path' Abs File')) -findWaspFile waspDir = do - files <- fst <$> IOUtil.listDirectory waspDir - return $ maybeToEither "Couldn't find a single *.wasp file." $ (waspDir ) <$> find isWaspFile files - where - isWaspFile path = - ".wasp" `isSuffixOf` toFilePath path - && (length (toFilePath path) > length (".wasp" :: String)) - -findDotEnvServer :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File')) -findDotEnvServer waspDir = findFileInWaspProjectDir waspDir dotEnvServer - -findDotEnvClient :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File')) -findDotEnvClient waspDir = findFileInWaspProjectDir waspDir dotEnvClient - -findDotEnv :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File')) -findDotEnv waspDir = findFileInWaspProjectDir waspDir [relfile|.env|] - -findFileInWaspProjectDir :: - Path' Abs (Dir WaspProjectDir) -> - Path' (Rel WaspProjectDir) File' -> - IO (Maybe (Path' Abs File')) -findFileInWaspProjectDir waspDir file = do - let fileAbsFp = waspDir file - fileExists <- doesFileExist $ toFilePath fileAbsFp - return $ if fileExists then Just fileAbsFp else Nothing - -findMigrationsDir :: - Path' Abs (Dir WaspProjectDir) -> - IO (Maybe (Path' Abs (Dir DbMigrationsDir))) -findMigrationsDir waspDir = do - let migrationsAbsPath = waspDir dbMigrationsDirInWaspProjectDir - migrationsExists <- doesDirectoryExist $ fromAbsDir migrationsAbsPath - return $ if migrationsExists then Just migrationsAbsPath else Nothing - -loadUserDockerfileContents :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe Text) -loadUserDockerfileContents waspDir = do - let dockerfileAbsPath = toFilePath $ waspDir [relfile|Dockerfile|] - whenMaybeM (doesFileExist dockerfileAbsPath) $ T.IO.readFile dockerfileAbsPath - -compileAndRenderDockerfile :: Path' Abs (Dir WaspProjectDir) -> CompileOptions -> IO (Either [CompileError] Text) -compileAndRenderDockerfile waspDir compileOptions = do - appSpecOrAnalyzerErrors <- analyzeWaspProject waspDir compileOptions - case appSpecOrAnalyzerErrors of - Left errors -> return $ Left errors - Right appSpec -> do - dockerfileOrGeneratorErrors <- DockerGenerator.compileAndRenderDockerfile appSpec - return $ left (map show . toList) dockerfileOrGeneratorErrors - --- | This will run our TS deploy project by passing all args from the Wasp CLI straight through. --- The TS project is compiled to JS in CI and included in the data dir for the release archive. --- If the project was not yet built locally (i.e. after they just installed a Wasp version), we do so. -deploy :: FilePath -> Path' Abs (Dir WaspProjectDir) -> [String] -> IO () -deploy waspExe waspDir cmdArgs = do - waspDataDir <- Data.getAbsDataDirPath - let deployDir = waspDataDir [reldir|packages/deploy|] - let nodeModulesDirExists = doesDirectoryExist . toFilePath $ deployDir [reldir|node_modules|] - unlessM nodeModulesDirExists $ - runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "npm" ["install"] J.Server - let deployScriptArgs = ["dist/index.js"] ++ cmdArgs ++ ["--wasp-exe", waspExe, "--wasp-project-dir", toFilePath waspDir] - -- NOTE: Here we are lying by saying we are running in the J.Server context. - -- TODO: Consider adding a new context for these types of things, like J.Other or J.External. - runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "node" deployScriptArgs J.Server - where - runCommandAndPrintOutput job = do - chan <- newChan - void $ concurrently (printJobMsgsUntilExitReceived chan) (job chan) diff --git a/waspc/src/Wasp/Project.hs b/waspc/src/Wasp/Project.hs new file mode 100644 index 000000000..49759824a --- /dev/null +++ b/waspc/src/Wasp/Project.hs @@ -0,0 +1,56 @@ +-- | "Project" here stands for a Wasp source project, and this module offers +-- logic for operating on and processing a Wasp source project, as a whole. +module Wasp.Project + ( WaspProjectDir, + compile, + CompileError, + CompileWarning, + analyzeWaspProject, + compileAndRenderDockerfile, + ) +where + +import Control.Arrow (ArrowChoice (left)) +import Data.List.NonEmpty (toList) +import Data.Maybe (maybeToList) +import Data.Text (Text) +import StrongPath (Abs, Dir, Path') +import qualified Wasp.AppSpec as AS +import Wasp.CompileOptions (CompileOptions (generatorWarningsFilter), sendMessage) +import qualified Wasp.Generator as Generator +import qualified Wasp.Generator.DockerGenerator as DockerGenerator +import Wasp.Project.Analyze (analyzeWaspProject) +import Wasp.Project.Common (CompileError, CompileWarning, WaspProjectDir) +import qualified Wasp.Project.Env as Project.Env + +compile :: + Path' Abs (Dir WaspProjectDir) -> + Path' Abs (Dir Generator.ProjectRootDir) -> + CompileOptions -> + IO ([CompileWarning], [CompileError]) +compile waspDir outDir options = do + compileWarningsAndErrors <- + analyzeWaspProject waspDir options >>= \case + Left analyzerErrors -> return ([], analyzerErrors) + Right appSpec -> generateCode appSpec outDir options + dotEnvWarnings <- maybeToList <$> Project.Env.warnIfTheDotEnvPresent waspDir + return $ (dotEnvWarnings, []) <> compileWarningsAndErrors + +generateCode :: + AS.AppSpec -> + Path' Abs (Dir Generator.ProjectRootDir) -> + CompileOptions -> + IO ([CompileError], [CompileWarning]) +generateCode appSpec outDir options = do + (generatorWarnings, generatorErrors) <- Generator.writeWebAppCode appSpec outDir (sendMessage options) + let filteredWarnings = generatorWarningsFilter options generatorWarnings + return (show <$> filteredWarnings, show <$> generatorErrors) + +compileAndRenderDockerfile :: Path' Abs (Dir WaspProjectDir) -> CompileOptions -> IO (Either [CompileError] Text) +compileAndRenderDockerfile waspDir compileOptions = do + appSpecOrAnalyzerErrors <- analyzeWaspProject waspDir compileOptions + case appSpecOrAnalyzerErrors of + Left errors -> return $ Left errors + Right appSpec -> do + dockerfileOrGeneratorErrors <- DockerGenerator.compileAndRenderDockerfile appSpec + return $ Control.Arrow.left (map show . toList) dockerfileOrGeneratorErrors diff --git a/waspc/src/Wasp/Project/Analyze.hs b/waspc/src/Wasp/Project/Analyze.hs new file mode 100644 index 000000000..af1ce8fc6 --- /dev/null +++ b/waspc/src/Wasp/Project/Analyze.hs @@ -0,0 +1,90 @@ +module Wasp.Project.Analyze + ( analyzeWaspProject, + ) +where + +import Control.Arrow (ArrowChoice (left)) +import Control.Monad.Except (ExceptT (ExceptT), runExceptT) +import Data.List (find, isSuffixOf) +import StrongPath (Abs, Dir, File', Path', toFilePath, ()) +import qualified Wasp.Analyzer as Analyzer +import Wasp.Analyzer.AnalyzeError (getErrorMessageAndCtx) +import qualified Wasp.AppSpec as AS +import Wasp.AppSpec.Valid (validateAppSpec) +import Wasp.CompileOptions (CompileOptions) +import qualified Wasp.CompileOptions as CompileOptions +import qualified Wasp.ConfigFile as CF +import Wasp.Error (showCompilerErrorForTerminal) +import qualified Wasp.ExternalCode as ExternalCode +import qualified Wasp.Generator.ConfigFile as G.CF +import Wasp.Project.Common (CompileError, WaspProjectDir) +import Wasp.Project.Db (makeDevDatabaseUrl) +import Wasp.Project.Db.Migrations (findMigrationsDir) +import Wasp.Project.Deployment (loadUserDockerfileContents) +import Wasp.Project.Env (readDotEnvClient, readDotEnvServer) +import Wasp.Util (maybeToEither) +import qualified Wasp.Util.IO as IOUtil + +analyzeWaspProject :: + Path' Abs (Dir WaspProjectDir) -> + CompileOptions -> + IO (Either [CompileError] AS.AppSpec) +analyzeWaspProject waspDir options = runExceptT $ do + waspFilePath <- ExceptT $ Control.Arrow.left pure <$> findWaspFile waspDir + declarations <- ExceptT $ Control.Arrow.left pure <$> analyzeWaspFileContent waspFilePath + ExceptT $ constructAppSpec waspDir options declarations + +analyzeWaspFileContent :: Path' Abs File' -> IO (Either CompileError [AS.Decl]) +analyzeWaspFileContent waspFilePath = do + waspFileContent <- IOUtil.readFile waspFilePath + let declsOrAnalyzeError = Analyzer.analyze waspFileContent + return $ + Control.Arrow.left + (showCompilerErrorForTerminal (waspFilePath, waspFileContent) . getErrorMessageAndCtx) + declsOrAnalyzeError + +constructAppSpec :: + Path' Abs (Dir WaspProjectDir) -> + CompileOptions -> + [AS.Decl] -> + IO (Either [CompileError] AS.AppSpec) +constructAppSpec waspDir options decls = do + externalServerCodeFiles <- + ExternalCode.readFiles (CompileOptions.externalServerCodeDirPath options) + externalClientCodeFiles <- + ExternalCode.readFiles (CompileOptions.externalClientCodeDirPath options) + externalSharedCodeFiles <- + ExternalCode.readFiles (CompileOptions.externalSharedCodeDirPath options) + maybeMigrationsDir <- findMigrationsDir waspDir + maybeUserDockerfileContents <- loadUserDockerfileContents waspDir + configFiles <- CF.discoverConfigFiles waspDir G.CF.configFileRelocationMap + let devDbUrl = makeDevDatabaseUrl waspDir decls + serverEnvVars <- readDotEnvServer waspDir + clientEnvVars <- readDotEnvClient waspDir + let appSpec = + AS.AppSpec + { AS.decls = decls, + AS.waspProjectDir = waspDir, + AS.externalClientFiles = externalClientCodeFiles, + AS.externalServerFiles = externalServerCodeFiles, + AS.externalSharedFiles = externalSharedCodeFiles, + AS.migrationsDir = maybeMigrationsDir, + AS.devEnvVarsServer = serverEnvVars, + AS.devEnvVarsClient = clientEnvVars, + AS.isBuild = CompileOptions.isBuild options, + AS.userDockerfileContents = maybeUserDockerfileContents, + AS.configFiles = configFiles, + AS.devDatabaseUrl = devDbUrl + } + return $ case validateAppSpec appSpec of + [] -> Right appSpec + validationErrors -> Left $ map show validationErrors + +findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Either String (Path' Abs File')) +findWaspFile waspDir = do + files <- fst <$> IOUtil.listDirectory waspDir + return $ maybeToEither "Couldn't find a single *.wasp file." $ (waspDir ) <$> find isWaspFile files + where + isWaspFile path = + ".wasp" `isSuffixOf` toFilePath path + && (length (toFilePath path) > length (".wasp" :: String)) diff --git a/waspc/src/Wasp/Project/Common.hs b/waspc/src/Wasp/Project/Common.hs new file mode 100644 index 000000000..4ad391914 --- /dev/null +++ b/waspc/src/Wasp/Project/Common.hs @@ -0,0 +1,25 @@ +module Wasp.Project.Common + ( findFileInWaspProjectDir, + CompileError, + CompileWarning, + WaspProjectDir, + ) +where + +import StrongPath (Abs, Dir, File', Path', Rel, toFilePath, ()) +import System.Directory (doesFileExist) + +data WaspProjectDir -- Root dir of Wasp project, containing source files. + +type CompileError = String + +type CompileWarning = String + +findFileInWaspProjectDir :: + Path' Abs (Dir WaspProjectDir) -> + Path' (Rel WaspProjectDir) File' -> + IO (Maybe (Path' Abs File')) +findFileInWaspProjectDir waspDir file = do + let fileAbsFp = waspDir file + fileExists <- doesFileExist $ toFilePath fileAbsFp + return $ if fileExists then Just fileAbsFp else Nothing diff --git a/waspc/src/Wasp/Project/Db.hs b/waspc/src/Wasp/Project/Db.hs new file mode 100644 index 000000000..b65655970 --- /dev/null +++ b/waspc/src/Wasp/Project/Db.hs @@ -0,0 +1,23 @@ +module Wasp.Project.Db + ( makeDevDatabaseUrl, + databaseUrlEnvVarName, + ) +where + +import StrongPath (Abs, Dir, Path') +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 Wasp.Project.Common (WaspProjectDir) +import qualified Wasp.Project.Db.Dev.Postgres as DevPostgres + +makeDevDatabaseUrl :: Path' Abs (Dir WaspProjectDir) -> [AS.Decl] -> Maybe String +makeDevDatabaseUrl waspProjectDir decls = do + (appName, app) <- AS.getApp decls + dbSystem <- AS.App.Db.system =<< AS.App.db app + case dbSystem of + AS.App.Db.SQLite -> Nothing + AS.App.Db.PostgreSQL -> Just $ DevPostgres.makeDevConnectionUrl waspProjectDir appName + +databaseUrlEnvVarName :: String +databaseUrlEnvVarName = "DATABASE_URL" diff --git a/waspc/src/Wasp/Project/Db/Dev.hs b/waspc/src/Wasp/Project/Db/Dev.hs new file mode 100644 index 000000000..ba19c94eb --- /dev/null +++ b/waspc/src/Wasp/Project/Db/Dev.hs @@ -0,0 +1,25 @@ +-- | This module and modules under it capture what Wasp knows about its dev database and running it. +module Wasp.Project.Db.Dev + ( makeDevDbUniqueId, + ) +where + +import Data.Char (isAsciiLower, isAsciiUpper, isDigit) +import StrongPath (Abs, Dir, Path', fromAbsDir) +import Wasp.Project.Common (WaspProjectDir) +import qualified Wasp.Util as U + +-- Returns a unique id that can be used for global identification of dev db tied to +-- a specific Wasp project. +-- Id is no longer than 30 chars, all of them ascii letters, numbers, hyphen or underscore. +-- It is designed this way to make it easily usable in many scenarios. +-- It contains app name (or big part of it), to make it also readable for humans. +-- It is not resistant to Wasp project moving or being renamed, and will change in that case. +-- TODO: Consider making this more general, make it a unique id of a project that can be used to +-- track (global) resources. So it would be not just for the db, but for anything. +makeDevDbUniqueId :: Path' Abs (Dir WaspProjectDir) -> String -> String +makeDevDbUniqueId waspProjectDir appName = take 19 sanitizedAppName <> "-" <> take 10 projectPathHash + where + projectPathHash = U.hexToString $ U.checksumFromString $ fromAbsDir waspProjectDir + sanitizedAppName = filter isSafeChar appName + isSafeChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_' || c == '-' diff --git a/waspc/src/Wasp/Project/Db/Dev/Postgres.hs b/waspc/src/Wasp/Project/Db/Dev/Postgres.hs new file mode 100644 index 000000000..72fd63759 --- /dev/null +++ b/waspc/src/Wasp/Project/Db/Dev/Postgres.hs @@ -0,0 +1,38 @@ +-- | This module captures how Wasp runs a PostgreSQL dev database. +module Wasp.Project.Db.Dev.Postgres + ( defaultDevUser, + makeDevDbName, + defaultDevPass, + defaultDevPort, + makeDevConnectionUrl, + ) +where + +import StrongPath (Abs, Dir, Path') +import Wasp.Db.Postgres (makeConnectionUrl, postgresMaxDbNameLength) +import Wasp.Project.Common (WaspProjectDir) +import Wasp.Project.Db.Dev (makeDevDbUniqueId) + +defaultDevUser :: String +defaultDevUser = "postgresWaspDevUser" + +defaultDevPass :: String +defaultDevPass = "postgresWaspDevPass" + +-- | Returns a db name that is unique for this Wasp project. +-- It depends on projects path and name, so if any of those change, +-- the db name will also change. +makeDevDbName :: Path' Abs (Dir WaspProjectDir) -> String -> String +makeDevDbName waspProjectDir appName = + -- We use makeDevDbUniqueId to construct a db name instead of a hardcoded value like "waspDevDb" + -- in order to avoid the situation where one Wasp app accidentally connects to a db that another + -- Wasp app has started. This way db name is unique for the specific Wasp app, and another Wasp app + -- can't connect to it by accident. + take postgresMaxDbNameLength $ makeDevDbUniqueId waspProjectDir appName + +defaultDevPort :: Int +defaultDevPort = 5432 -- 5432 is default port for PostgreSQL db. + +makeDevConnectionUrl :: Path' Abs (Dir WaspProjectDir) -> String -> String +makeDevConnectionUrl waspProjectDir appName = + makeConnectionUrl defaultDevUser defaultDevPass defaultDevPort $ makeDevDbName waspProjectDir appName diff --git a/waspc/src/Wasp/Project/Db/Migrations.hs b/waspc/src/Wasp/Project/Db/Migrations.hs new file mode 100644 index 000000000..853ffc8ba --- /dev/null +++ b/waspc/src/Wasp/Project/Db/Migrations.hs @@ -0,0 +1,23 @@ +module Wasp.Project.Db.Migrations + ( DbMigrationsDir, + dbMigrationsDirInWaspProjectDir, + findMigrationsDir, + ) +where + +import StrongPath (Abs, Dir, Path', Rel, fromAbsDir, reldir, ()) +import System.Directory (doesDirectoryExist) +import Wasp.Project.Common (WaspProjectDir) + +data DbMigrationsDir + +dbMigrationsDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir DbMigrationsDir) +dbMigrationsDirInWaspProjectDir = [reldir|migrations|] + +findMigrationsDir :: + Path' Abs (Dir WaspProjectDir) -> + IO (Maybe (Path' Abs (Dir DbMigrationsDir))) +findMigrationsDir waspDir = do + let migrationsAbsPath = waspDir dbMigrationsDirInWaspProjectDir + migrationsExists <- doesDirectoryExist $ fromAbsDir migrationsAbsPath + return $ if migrationsExists then Just migrationsAbsPath else Nothing diff --git a/waspc/src/Wasp/Project/Deployment.hs b/waspc/src/Wasp/Project/Deployment.hs new file mode 100644 index 000000000..2e3ec1e25 --- /dev/null +++ b/waspc/src/Wasp/Project/Deployment.hs @@ -0,0 +1,44 @@ +module Wasp.Project.Deployment + ( loadUserDockerfileContents, + deploy, + ) +where + +import Control.Concurrent (newChan) +import Control.Concurrent.Async (concurrently) +import Control.Monad.Except (void) +import Control.Monad.Extra (whenMaybeM) +import Data.Text (Text) +import qualified Data.Text.IO as T.IO +import StrongPath (Abs, Dir, Path', reldir, relfile, toFilePath, ()) +import System.Directory (doesDirectoryExist, doesFileExist) +import qualified Wasp.Data as Data +import qualified Wasp.Generator.Job as J +import Wasp.Generator.Job.IO (printJobMsgsUntilExitReceived) +import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Project.Common (WaspProjectDir) +import Wasp.Util (unlessM) + +loadUserDockerfileContents :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe Text) +loadUserDockerfileContents waspDir = do + let dockerfileAbsPath = toFilePath $ waspDir [relfile|Dockerfile|] + whenMaybeM (doesFileExist dockerfileAbsPath) $ T.IO.readFile dockerfileAbsPath + +-- | This will run our TS deploy project by passing all args from the Wasp CLI straight through. +-- The TS project is compiled to JS in CI and included in the data dir for the release archive. +-- If the project was not yet built locally (i.e. after they just installed a Wasp version), we do so. +deploy :: FilePath -> Path' Abs (Dir WaspProjectDir) -> [String] -> IO () +deploy waspExe waspDir cmdArgs = do + waspDataDir <- Data.getAbsDataDirPath + let deployDir = waspDataDir [reldir|packages/deploy|] + let nodeModulesDirExists = doesDirectoryExist . toFilePath $ deployDir [reldir|node_modules|] + unlessM nodeModulesDirExists $ + runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "npm" ["install"] J.Server + let deployScriptArgs = ["dist/index.js"] ++ cmdArgs ++ ["--wasp-exe", waspExe, "--wasp-project-dir", toFilePath waspDir] + -- NOTE: Here we are lying by saying we are running in the J.Server context. + -- TODO: Consider adding a new context for these types of things, like J.Other or J.External. + runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "node" deployScriptArgs J.Server + where + runCommandAndPrintOutput job = do + chan <- newChan + void $ concurrently (printJobMsgsUntilExitReceived chan) (job chan) diff --git a/waspc/src/Wasp/Project/Env.hs b/waspc/src/Wasp/Project/Env.hs new file mode 100644 index 000000000..26304df6c --- /dev/null +++ b/waspc/src/Wasp/Project/Env.hs @@ -0,0 +1,45 @@ +module Wasp.Project.Env + ( readDotEnvServer, + readDotEnvClient, + warnIfTheDotEnvPresent, + dotEnvServer, + dotEnvClient, + ) +where + +import StrongPath (Abs, Dir, File', Path', Rel, relfile) +import Wasp.Env (EnvVar, parseDotEnvFile) +import Wasp.Project.Common (CompileWarning, WaspProjectDir, findFileInWaspProjectDir) + +dotEnvServer :: Path' (Rel WaspProjectDir) File' +dotEnvServer = [relfile|.env.server|] + +dotEnvClient :: Path' (Rel WaspProjectDir) File' +dotEnvClient = [relfile|.env.client|] + +readDotEnvServer :: Path' Abs (Dir WaspProjectDir) -> IO [EnvVar] +readDotEnvServer waspDir = readDotEnvFileInWaspProjectDir waspDir dotEnvServer + +readDotEnvClient :: Path' Abs (Dir WaspProjectDir) -> IO [EnvVar] +readDotEnvClient waspDir = readDotEnvFileInWaspProjectDir waspDir dotEnvServer + +-- | Checks if .env exists in wasp dir, and produces a warning if so. +-- We have this function because Wasp doesn't use ".env", but still user +-- might assume it does and then bother quite a bit trying to figure it out, +-- so this way we warn if they assume so. +warnIfTheDotEnvPresent :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe CompileWarning) +warnIfTheDotEnvPresent waspDir = (warningMessage <$) <$> findFileInWaspProjectDir waspDir [relfile|.env|] + where + warningMessage = "Wasp .env files should be named .env.server or .env.client, depending on their use." + +-- Reads specified dotenv file and returns its values. +-- If file doesn't exist, returns an empty list. +-- If file can't be parsed, it will crash with an error. +readDotEnvFileInWaspProjectDir :: + Path' Abs (Dir WaspProjectDir) -> + Path' (Rel WaspProjectDir) File' -> + IO [(String, String)] +readDotEnvFileInWaspProjectDir waspDir envFileInWaspDir = do + findFileInWaspProjectDir waspDir envFileInWaspDir >>= \case + Nothing -> return [] + Just envFile -> parseDotEnvFile envFile diff --git a/waspc/src/Wasp/Util.hs b/waspc/src/Wasp/Util.hs index 3ca45c58e..22c3a452d 100644 --- a/waspc/src/Wasp/Util.hs +++ b/waspc/src/Wasp/Util.hs @@ -32,11 +32,13 @@ module Wasp.Util orIfNothingM, kebabToCamelCase, maybeToEither, + whenM, + trim, ) where import Control.Applicative (liftA2) -import Control.Monad (unless) +import Control.Monad (unless, when) import qualified Crypto.Hash.SHA256 as SHA256 import qualified Data.Aeson as Aeson import qualified Data.ByteString as B @@ -167,6 +169,9 @@ insertAt theInsert idx host = let (before, after) = splitAt idx host in before ++ theInsert ++ after +trim :: String -> String +trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace + infixr 5 <++> (<++>) :: Applicative f => f [a] -> f [a] -> f [a] @@ -180,6 +185,9 @@ infixr 5 <:> ifM :: Monad m => m Bool -> m a -> m a -> m a ifM p x y = p >>= \b -> if b then x else y +whenM :: Monad m => m Bool -> m () -> m () +whenM ma mb = ma >>= (`when` mb) + unlessM :: Monad m => m Bool -> m () -> m () unlessM ma mb = ma >>= (`unless` mb) diff --git a/waspc/src/Wasp/Util/IO.hs b/waspc/src/Wasp/Util/IO.hs index aa19cf40e..2c4b38686 100644 --- a/waspc/src/Wasp/Util/IO.hs +++ b/waspc/src/Wasp/Util/IO.hs @@ -10,6 +10,8 @@ module Wasp.Util.IO readFileStrict, writeFile, removeFile, + isDirectoryEmpty, + writeFileFromText, ) where @@ -99,5 +101,13 @@ readFileStrict = T.IO.readFile . SP.toFilePath writeFile :: Path' Abs (File f) -> String -> IO () writeFile = P.writeFile . SP.fromAbsFile +writeFileFromText :: Path' Abs (File f) -> Text -> IO () +writeFileFromText = T.IO.writeFile . SP.fromAbsFile + removeFile :: Path' Abs (File f) -> IO () removeFile = SD.removeFile . SP.fromAbsFile + +isDirectoryEmpty :: Path' Abs (Dir d) -> IO Bool +isDirectoryEmpty dirPath = do + (files, dirs) <- listDirectory dirPath + return $ null files && null dirs diff --git a/waspc/src/Wasp/Util/Network/Socket.hs b/waspc/src/Wasp/Util/Network/Socket.hs new file mode 100644 index 000000000..03d529228 --- /dev/null +++ b/waspc/src/Wasp/Util/Network/Socket.hs @@ -0,0 +1,81 @@ +module Wasp.Util.Network.Socket + ( checkIfPortIsAcceptingConnections, + checkIfPortIsInUse, + checkIfPortCanBeOpened, + makeSocketAddress, + makeLocalHostSocketAddress, + IPv4, + ) +where + +import Data.Word (Word8) +import Foreign.C.Error (Errno (..), eADDRINUSE, eCONNREFUSED) +import GHC.IO.Exception (IOException (..)) +import qualified Network.Socket as S +import UnliftIO.Exception (bracket, throwIO, try) + +-- | Tests if port is accepting connections. +-- Does so by trying to connect via socket to it (connection is closed immediately). +-- It returns True if connection succeeds, or False if connection is refused +-- (because port is not opened, nobody is listening on it). +-- Rethrows connection exceptions in all other cases (e.g. when the host +-- is unroutable). +checkIfPortIsAcceptingConnections :: S.SockAddr -> IO Bool +checkIfPortIsAcceptingConnections sockAddr = do + bracket createSocket S.close' $ \sock -> + try + ( do + S.connect sock sockAddr + ) + >>= \case + Right () -> return True + Left e -> + if isConnRefusedException e + then return False + else throwIO e + where + createSocket = createIPv4TCPSocket + isConnRefusedException e = (Errno <$> ioe_errno e) == Just eCONNREFUSED + +-- | True if port is in use, False if it is free, exception in all other cases. +checkIfPortIsInUse :: S.SockAddr -> IO Bool +checkIfPortIsInUse = (not <$>) . checkIfPortCanBeOpened + +-- | Tests if port can be opened. +-- Does so by trying to bind a socket to it (and then closing it immediately). +-- Returns True if it can be opened, False if it is already in use, and throws +-- an exception in all other cases (e.g. when the host is unroutable). +checkIfPortCanBeOpened :: S.SockAddr -> IO Bool +checkIfPortCanBeOpened sockAddr = do + bracket createSocket S.close' $ \sock -> + try + ( do + S.bind sock sockAddr + S.listen sock queueLength + ) + >>= \case + Right () -> return True + Left e -> + if isAddrInUseException e + then return False + else throwIO e + where + createSocket = do + sock <- createIPv4TCPSocket + S.setSocketOption sock S.ReuseAddr 1 -- Connect even if port is in TIME_WAIT state. + return sock + queueLength = 1 + isAddrInUseException e = (Errno <$> ioe_errno e) == Just eADDRINUSE + +createIPv4TCPSocket :: IO S.Socket +createIPv4TCPSocket = S.socket S.AF_INET S.Stream S.defaultProtocol + +-- | Creates a socket address from host IP and port number. +-- > makeSocketAddress (127,0,0,1) 8000 +makeSocketAddress :: IPv4 -> S.PortNumber -> S.SockAddr +makeSocketAddress hostIp port = S.SockAddrInet port $ S.tupleToHostAddress hostIp + +makeLocalHostSocketAddress :: S.PortNumber -> S.SockAddr +makeLocalHostSocketAddress = makeSocketAddress (127, 0, 0, 1) + +type IPv4 = (Word8, Word8, Word8, Word8) diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index dacf555a9..d48e2edd4 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -18,6 +18,7 @@ import qualified Wasp.AppSpec.App.Auth as Auth import qualified Wasp.AppSpec.App.Client as Client import qualified Wasp.AppSpec.App.Db as Db import qualified Wasp.AppSpec.App.Dependency as Dependency +import qualified Wasp.AppSpec.App.EmailSender as EmailSender import qualified Wasp.AppSpec.App.Server as Server import qualified Wasp.AppSpec.App.Wasp as Wasp import Wasp.AppSpec.Core.Ref (Ref (..)) @@ -61,7 +62,15 @@ spec_Analyzer = do " setupFn: import { setupClient } from \"@client/baz.js\"", " },", " db: {", - " system: PostgreSQL", + " system: PostgreSQL,", + " seeds: [ import { devSeedSimple } from \"@server/dbSeeds.js\" ]", + " },", + " emailSender: {", + " provider: SendGrid,", + " defaultFrom: {", + " email: \"test@test.com\",", + " name: \"Test\"", + " }", " }", "}", "", @@ -126,7 +135,8 @@ spec_Analyzer = do Auth.AuthMethods { Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig, Auth.google = Nothing, - Auth.gitHub = Nothing + Auth.gitHub = Nothing, + Auth.email = Nothing }, Auth.onAuthFailedRedirectTo = "/", Auth.onAuthSucceededRedirectTo = Nothing @@ -154,7 +164,28 @@ spec_Analyzer = do Just $ ExtImport (ExtImportField "App") (fromJust $ SP.parseRelFileP "App.jsx") }, - App.db = Just Db.Db {Db.system = Just Db.PostgreSQL} + App.db = + Just + Db.Db + { Db.system = Just Db.PostgreSQL, + Db.seeds = + Just + [ ExtImport + (ExtImportField "devSeedSimple") + (fromJust $ SP.parseRelFileP "dbSeeds.js") + ] + }, + App.emailSender = + Just + EmailSender.EmailSender + { EmailSender.provider = EmailSender.SendGrid, + EmailSender.defaultFrom = + Just + EmailSender.EmailFromField + { EmailSender.email = "test@test.com", + EmailSender.name = Just "Test" + } + } } ) ] diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index 3c019e67e..3262c37d2 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -9,12 +9,16 @@ import Test.Tasty.Hspec import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.Auth +import qualified Wasp.AppSpec.App.Auth.EmailVerification as AS.Auth.EmailVerification +import qualified Wasp.AppSpec.App.Auth.PasswordReset as AS.Auth.PasswordReset +import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender import qualified Wasp.AppSpec.App.Wasp as AS.Wasp import qualified Wasp.AppSpec.Core.Decl as AS.Decl import qualified Wasp.AppSpec.Core.Ref as AS.Core.Ref import qualified Wasp.AppSpec.Entity as AS.Entity import qualified Wasp.AppSpec.ExtImport as AS.ExtImport import qualified Wasp.AppSpec.Page as AS.Page +import qualified Wasp.AppSpec.Route as AS.Route import qualified Wasp.AppSpec.Valid as ASV import qualified Wasp.Psl.Ast.Model as PslM import qualified Wasp.SemanticVersion as SV @@ -90,6 +94,16 @@ spec_AppSpecValid = do PslM.ElementField $ makeBasicPslField "password" PslM.String ] ) + let validUserEntityForEmailAuth = + AS.Entity.makeEntity + ( PslM.Body + [ PslM.ElementField $ makePslField "email" PslM.String True, + PslM.ElementField $ makePslField "password" PslM.String True, + PslM.ElementField $ makePslField "isEmailVerified" PslM.Boolean False, + PslM.ElementField $ makePslField "emailVerificationSentAt" PslM.DateTime True, + PslM.ElementField $ makePslField "passwordResetSentAt" PslM.DateTime True + ] + ) let validAppAuth = AS.Auth.Auth { AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, @@ -98,7 +112,8 @@ spec_AppSpecValid = do AS.Auth.AuthMethods { AS.Auth.usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, AS.Auth.google = Nothing, - AS.Auth.gitHub = Nothing + AS.Auth.gitHub = Nothing, + AS.Auth.email = Nothing }, AS.Auth.onAuthFailedRedirectTo = "/", AS.Auth.onAuthSucceededRedirectTo = Nothing @@ -126,6 +141,69 @@ spec_AppSpecValid = do `shouldBe` [ ASV.GenericValidationError "Expected app.auth to be defined since there are Pages with authRequired set to true." ] + it "contains expected fields" $ do + ASV.doesUserEntityContainField (makeSpec Nothing Nothing) "password" `shouldBe` Nothing + ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "username" `shouldBe` Just True + ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "password" `shouldBe` Just True + ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "missing" `shouldBe` Just False + + describe "should validate that UsernameAndPassword and Email auth cannot used at the same time" $ do + let makeSpec authMethods userEntity = + basicAppSpec + { AS.decls = + [ AS.Decl.makeDecl "TestApp" $ + basicApp + { AS.App.auth = + Just + AS.Auth.Auth + { AS.Auth.methods = authMethods, + AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, + AS.Auth.externalAuthEntity = Nothing, + AS.Auth.onAuthFailedRedirectTo = "/", + AS.Auth.onAuthSucceededRedirectTo = Nothing + }, + AS.App.emailSender = + Just + AS.EmailSender.EmailSender + { AS.EmailSender.provider = AS.EmailSender.Mailgun, + AS.EmailSender.defaultFrom = Nothing + } + }, + AS.Decl.makeDecl userEntityName userEntity, + basicPageDecl, + basicRouteDecl + ] + } + let emailAuthConfig = + AS.Auth.EmailAuthConfig + { AS.Auth.fromField = + AS.EmailSender.EmailFromField + { AS.EmailSender.email = "dummy@info.com", + AS.EmailSender.name = Nothing + }, + AS.Auth.emailVerification = + AS.Auth.EmailVerification.EmailVerificationConfig + { AS.Auth.EmailVerification.clientRoute = AS.Core.Ref.Ref basicRouteName, + AS.Auth.EmailVerification.getEmailContentFn = Nothing + }, + AS.Auth.passwordReset = + AS.Auth.PasswordReset.PasswordResetConfig + { AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName, + AS.Auth.PasswordReset.getEmailContentFn = Nothing + }, + AS.Auth.allowUnverifiedLogin = Nothing + } + + it "returns no error if app.auth is not set" $ do + ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` [] + + it "returns no error if app.auth is set and only one of UsernameAndPassword and Email is used" $ do + ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` [] + ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntityForEmailAuth) `shouldBe` [] + + it "returns an error if app.auth is set and both UsernameAndPassword and Email are used" $ do + ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntity) + `shouldContain` [ASV.GenericValidationError "Expected app.auth to use either email or username and password authentication, but not both."] describe "should validate that when app.auth is using UsernameAndPassword, user entity is of valid shape." $ do let makeSpec appAuth userEntity = @@ -165,11 +243,15 @@ spec_AppSpecValid = do "Expected an Entity referenced by app.auth.userEntity to have field 'password' of type 'String'." ] where - makeBasicPslField name typ = + makeBasicPslField name typ = makePslField name typ False + + makePslField name typ isOptional = PslM.Field { PslM._name = name, PslM._type = typ, - PslM._typeModifiers = [], + PslM._typeModifiers = + [ PslM.Optional | isOptional + ], PslM._attrs = [] } @@ -185,7 +267,8 @@ spec_AppSpecValid = do AS.App.client = Nothing, AS.App.auth = Nothing, AS.App.dependencies = Nothing, - AS.App.head = Nothing + AS.App.head = Nothing, + AS.App.emailSender = Nothing } basicAppDecl = AS.Decl.makeDecl "TestApp" basicApp @@ -199,10 +282,11 @@ spec_AppSpecValid = do AS.externalSharedFiles = [], AS.isBuild = False, AS.migrationsDir = Nothing, - AS.dotEnvServerFile = Nothing, - AS.dotEnvClientFile = Nothing, + AS.devEnvVarsClient = [], + AS.devEnvVarsServer = [], AS.userDockerfileContents = Nothing, - AS.configFiles = [] + AS.configFiles = [], + AS.devDatabaseUrl = Nothing } basicPage = @@ -213,3 +297,13 @@ spec_AppSpecValid = do (fromJust $ SP.parseRelFileP "pages/Main"), AS.Page.authRequired = Nothing } + + basicPageName = "TestPage" + + basicPageDecl = AS.Decl.makeDecl basicPageName basicPage + + basicRoute = AS.Route.Route {AS.Route.to = AS.Core.Ref.Ref basicPageName, AS.Route.path = "/test"} + + basicRouteName = "TestRoute" + + basicRouteDecl = AS.Decl.makeDecl basicRouteName basicRoute diff --git a/waspc/test/Generator/FileDraft/CopyAndModifyTextFileDraftTest.hs b/waspc/test/Generator/FileDraft/CopyAndModifyTextFileDraftTest.hs new file mode 100644 index 000000000..1a63400bf --- /dev/null +++ b/waspc/test/Generator/FileDraft/CopyAndModifyTextFileDraftTest.hs @@ -0,0 +1,27 @@ +module Generator.FileDraft.CopyAndModifyTextFileDraftTest where + +import Fixtures (systemSPRoot) +import qualified Generator.MockWriteableMonad as Mock +import StrongPath (parent, reldir, relfile, toFilePath, ()) +import Test.Tasty.Hspec +import Wasp.Generator.FileDraft + +spec_CopyAndModifyTextFileDraft :: Spec +spec_CopyAndModifyTextFileDraft = do + describe "write" $ do + it "Creates new file by copying existing text file and applying modifications to it" $ do + let mock = write dstDir fileDraft + let mockLogs = Mock.getMockLogs mock Mock.defaultMockConfig + Mock.createDirectoryIfMissing_calls mockLogs + `shouldBe` [(True, toFilePath $ parent expectedDstPath)] + Mock.readFileAsText_calls mockLogs + `shouldBe` [toFilePath expectedSrcPath] + Mock.writeFileFromText_calls mockLogs + `shouldBe` [(toFilePath expectedDstPath, "First line\nMock text file content")] + where + dstDir = systemSPRoot [reldir|a/b|] + dstPath = [relfile|c/d/dst.txt|] + srcPath = systemSPRoot [relfile|e/src.txt|] + fileDraft = createCopyAndModifyTextFileDraft dstPath srcPath ("First line\n" <>) + expectedSrcPath = srcPath + expectedDstPath = dstDir dstPath diff --git a/waspc/test/Generator/MockWriteableMonad.hs b/waspc/test/Generator/MockWriteableMonad.hs index d39e6553d..e1209a164 100644 --- a/waspc/test/Generator/MockWriteableMonad.hs +++ b/waspc/test/Generator/MockWriteableMonad.hs @@ -33,18 +33,24 @@ defaultMockConfig = getTemplateFileAbsPath_impl = \path -> systemSPRoot [reldir|mock/templates/dir|] path, compileAndRenderTemplate_impl = \_ _ -> pack "Mock template content", doesFileExist_impl = const True, - doesDirectoryExist_impl = const True + doesDirectoryExist_impl = const True, + readFileAsText_impl = \_ -> pack "Mock text file content" } getMockLogs :: MockWriteableMonad a -> MockWriteableMonadConfig -> MockWriteableMonadLogs getMockLogs mock config = fst $ execState (unMockWriteableMonad mock) (emptyLogs, config) where - emptyLogs = MockWriteableMonadLogs [] [] [] [] [] [] [] [] + emptyLogs = MockWriteableMonadLogs [] [] [] [] [] [] [] [] [] instance WriteableMonad MockWriteableMonad where writeFileFromText dstPath text = MockWriteableMonad $ do modifyLogs (writeFileFromText_addCall dstPath text) + readFileAsText srcPath = MockWriteableMonad $ do + modifyLogs (readFileAsText_addCall srcPath) + (_, config) <- get + return $ readFileAsText_impl config srcPath + getTemplatesDirAbsPath = MockWriteableMonad $ do modifyLogs getTemplatesDirAbsPath_addCall (_, config) <- get @@ -95,6 +101,7 @@ newtype MockWriteableMonad a = MockWriteableMonad data MockWriteableMonadLogs = MockWriteableMonadLogs { writeFileFromText_calls :: [(FilePath, Text)], + readFileAsText_calls :: [FilePath], getTemplatesDirAbsPath_calls :: [()], createDirectoryIfMissing_calls :: [(Bool, FilePath)], copyFile_calls :: [(FilePath, FilePath)], @@ -109,13 +116,18 @@ data MockWriteableMonadConfig = MockWriteableMonadConfig getTemplateFileAbsPath_impl :: forall a. Path' (Rel TemplatesDir) (File a) -> Path' Abs (File a), compileAndRenderTemplate_impl :: forall a. Path' (Rel TemplatesDir) (File a) -> Aeson.Value -> Text, doesFileExist_impl :: FilePath -> Bool, - doesDirectoryExist_impl :: FilePath -> Bool + doesDirectoryExist_impl :: FilePath -> Bool, + readFileAsText_impl :: FilePath -> Text } writeFileFromText_addCall :: FilePath -> Text -> MockWriteableMonadLogs -> MockWriteableMonadLogs writeFileFromText_addCall path text logs = logs {writeFileFromText_calls = (path, text) : writeFileFromText_calls logs} +readFileAsText_addCall :: FilePath -> MockWriteableMonadLogs -> MockWriteableMonadLogs +readFileAsText_addCall path logs = + logs {readFileAsText_calls = path : readFileAsText_calls logs} + getTemplatesDirAbsPath_addCall :: MockWriteableMonadLogs -> MockWriteableMonadLogs getTemplatesDirAbsPath_addCall logs = logs {getTemplatesDirAbsPath_calls = () : getTemplatesDirAbsPath_calls logs} diff --git a/waspc/test/Generator/WebAppGeneratorTest.hs b/waspc/test/Generator/WebAppGeneratorTest.hs index 65b7b6882..8b8f4285a 100644 --- a/waspc/test/Generator/WebAppGeneratorTest.hs +++ b/waspc/test/Generator/WebAppGeneratorTest.hs @@ -9,6 +9,7 @@ import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Wasp as AS.Wasp import qualified Wasp.AppSpec.Core.Decl as AS.Decl import Wasp.Generator.FileDraft +import qualified Wasp.Generator.FileDraft.CopyAndModifyTextFileDraft as CMTextFD import qualified Wasp.Generator.FileDraft.CopyDirFileDraft as CopyDirFD import qualified Wasp.Generator.FileDraft.CopyFileDraft as CopyFD import qualified Wasp.Generator.FileDraft.TemplateFileDraft as TmplFD @@ -39,7 +40,8 @@ spec_WebAppGenerator = do AS.App.client = Nothing, AS.App.auth = Nothing, AS.App.dependencies = Nothing, - AS.App.head = Nothing + AS.App.head = Nothing, + AS.App.emailSender = Nothing } ], AS.waspProjectDir = systemSPRoot SP. [SP.reldir|test/|], @@ -48,10 +50,11 @@ spec_WebAppGenerator = do AS.externalSharedFiles = [], AS.isBuild = False, AS.migrationsDir = Nothing, - AS.dotEnvServerFile = Nothing, - AS.dotEnvClientFile = Nothing, + AS.devEnvVarsServer = [], + AS.devEnvVarsClient = [], AS.userDockerfileContents = Nothing, - AS.configFiles = [] + AS.configFiles = [], + AS.devDatabaseUrl = Nothing } describe "genWebApp" $ do @@ -101,3 +104,4 @@ getFileDraftDstPath (FileDraftTemplateFd fd) = SP.toFilePath $ TmplFD._dstPath f getFileDraftDstPath (FileDraftCopyFd fd) = SP.toFilePath $ CopyFD._dstPath fd getFileDraftDstPath (FileDraftCopyDirFd fd) = SP.toFilePath $ CopyDirFD._dstPath fd getFileDraftDstPath (FileDraftTextFd fd) = SP.toFilePath $ TextFD._dstPath fd +getFileDraftDstPath (FileDraftCopyAndModifyTextFd fd) = SP.toFilePath $ CMTextFD._dstPath fd diff --git a/waspc/test/Generator/WriteFileDraftsTest.hs b/waspc/test/Generator/WriteFileDraftsTest.hs index 4214c52c4..57a3da22a 100644 --- a/waspc/test/Generator/WriteFileDraftsTest.hs +++ b/waspc/test/Generator/WriteFileDraftsTest.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + module Generator.WriteFileDraftsTest where import Data.Bifunctor (Bifunctor (first)) @@ -34,6 +36,14 @@ spec_WriteDuplicatedDstFileDrafts = let fileDrafts = map FileDraftTextFd (genMockTextFileDrafts 2) in (return $! assertDstPathsAreUnique fileDrafts) `shouldReturn` () +-- NOTE: Very weak show function, but it is good enough for the tests below. +instance Show FileDraft where + show fd = "FileDraft {dstPath = " ++ show (getDstPath fd) ++ "}" + +-- NOTE: Very weak eq function, but it is good enough for the tests below. +instance Eq FileDraft where + fd1 == fd2 = getDstPath fd1 == getDstPath fd2 + spec_WriteFileDrafts :: Spec spec_WriteFileDrafts = describe "fileDraftsToWriteAndFilesToDelete" $ do diff --git a/waspc/test/WaspignoreFileTest.hs b/waspc/test/WaspignoreFileTest.hs index 7043fd888..3392aa1de 100644 --- a/waspc/test/WaspignoreFileTest.hs +++ b/waspc/test/WaspignoreFileTest.hs @@ -1,7 +1,7 @@ module WaspignoreFileTest where import Test.Tasty.Hspec -import Test.Tasty.QuickCheck (property) +import Test.Tasty.QuickCheck (arbitraryPrintableChar, forAll, listOf, property) import Wasp.WaspignoreFile (ignores, parseWaspignoreFile) spec_IgnoreFile :: Spec @@ -15,26 +15,31 @@ spec_IgnoreFile = do it "When given a blank input, should match only '.waspignore'" $ do let ignoreFile = parseWaspignoreFile "" - property $ \fp -> - if fp == ".waspignore" - then ignoreFile `ignores` fp - else not $ ignoreFile `ignores` fp + property $ + forAll (listOf arbitraryPrintableChar) $ \fp -> + if fp == ".waspignore" + then ignoreFile `ignores` fp + else not $ ignoreFile `ignores` fp it "When given a comment as the only line, should match only '.waspignore'" $ do let ignoreFile = parseWaspignoreFile "# test comment" - property $ \fp -> - if fp == ".waspignore" - then ignoreFile `ignores` fp - else not $ ignoreFile `ignores` fp + property $ + forAll (listOf arbitraryPrintableChar) $ \fp -> + if fp == ".waspignore" + then ignoreFile `ignores` fp + else not $ ignoreFile `ignores` fp it "When the only difference between two files is a comment, the files should match the same strings" $ do let comment = "\n# test comment" - property $ \pat fp -> - (parseWaspignoreFile pat `ignores` fp) - == (parseWaspignoreFile (pat ++ comment) `ignores` fp) + property $ + forAll (listOf arbitraryPrintableChar) $ \pat fp -> + (parseWaspignoreFile pat `ignores` fp) + == (parseWaspignoreFile (pat ++ comment) `ignores` fp) it "When given 2 patterns, should match the path if either of the patterns match" $ do let pat1 = parseWaspignoreFile "a" let pat2 = parseWaspignoreFile "b" let patBoth = parseWaspignoreFile "a\nb" - property $ \fp -> patBoth `ignores` fp == (pat1 `ignores` fp || pat2 `ignores` fp) + property $ + forAll (listOf arbitraryPrintableChar) $ \fp -> + patBoth `ignores` fp == (pat1 `ignores` fp || pat2 `ignores` fp) diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index ec721d81a..e33cc53bd 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -6,7 +6,7 @@ cabal-version: 2.4 -- Consider using hpack, or maybe even hpack-dhall. name: waspc -version: 0.9.0 +version: 0.10.0 description: Please see the README on GitHub at homepage: https://github.com/wasp-lang/wasp/waspc#readme bug-reports: https://github.com/wasp-lang/wasp/issues @@ -124,6 +124,8 @@ library , array ^>= 0.5.4 , deepseq ^>= 1.4.4 , extra ^>= 1.7.10 + , dotenv ^>= 0.10.0 + , network ^>= 3.1.2 other-modules: Paths_waspc exposed-modules: FilePath.Extra @@ -182,10 +184,14 @@ library Wasp.Analyzer.TypeDefinitions.TH.Enum Wasp.AppSpec Wasp.AppSpec.Action + Wasp.AppSpec.Api Wasp.AppSpec.App Wasp.AppSpec.App.Auth + Wasp.AppSpec.App.Auth.PasswordReset + Wasp.AppSpec.App.Auth.EmailVerification Wasp.AppSpec.App.Client Wasp.AppSpec.App.Db + Wasp.AppSpec.App.EmailSender Wasp.AppSpec.App.Dependency Wasp.AppSpec.App.Server Wasp.AppSpec.App.Wasp @@ -204,11 +210,12 @@ library Wasp.AppSpec.Route Wasp.AppSpec.Valid Wasp.AppSpec.Util - Wasp.Common Wasp.CompileOptions Wasp.ConfigFile Wasp.Data + Wasp.Db.Postgres Wasp.Error + Wasp.Env Wasp.ExternalCode Wasp.JsImport Wasp.Generator @@ -228,6 +235,7 @@ library Wasp.Generator.FileDraft.CopyFileDraft Wasp.Generator.FileDraft.TemplateFileDraft Wasp.Generator.FileDraft.TextFileDraft + Wasp.Generator.FileDraft.CopyAndModifyTextFileDraft Wasp.Generator.FileDraft.Writeable Wasp.Generator.FileDraft.WriteableMonad Wasp.Generator.Job @@ -237,12 +245,23 @@ library Wasp.Generator.Job.Process Wasp.Generator.JsImport Wasp.Generator.Monad + Wasp.Generator.AuthProviders + Wasp.Generator.AuthProviders.Common + Wasp.Generator.AuthProviders.OAuth + Wasp.Generator.AuthProviders.Local + Wasp.Generator.AuthProviders.Email Wasp.Generator.ServerGenerator Wasp.Generator.ServerGenerator.JsImport + Wasp.Generator.ServerGenerator.ApiRoutesG Wasp.Generator.ServerGenerator.AuthG + Wasp.Generator.ServerGenerator.Auth.OAuthAuthG + Wasp.Generator.ServerGenerator.Auth.LocalAuthG + Wasp.Generator.ServerGenerator.Auth.EmailAuthG + Wasp.Generator.ServerGenerator.Db.Seed + Wasp.Generator.ServerGenerator.EmailSenderG + Wasp.Generator.ServerGenerator.EmailSender.Providers Wasp.Generator.ServerGenerator.Common Wasp.Generator.ServerGenerator.ConfigG - Wasp.Generator.ServerGenerator.ExternalAuthG Wasp.Generator.ServerGenerator.ExternalCodeGenerator Wasp.Generator.ServerGenerator.JobGenerator Wasp.Generator.ServerGenerator.OperationsG @@ -252,25 +271,39 @@ library Wasp.Generator.Setup Wasp.Generator.Start Wasp.Generator.Templates + Wasp.Generator.Test Wasp.Generator.WebAppGenerator Wasp.Generator.WebAppGenerator.JsImport Wasp.Generator.WebAppGenerator.AuthG + Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG + Wasp.Generator.WebAppGenerator.Auth.LocalAuthG + Wasp.Generator.WebAppGenerator.Auth.EmailAuthG + Wasp.Generator.WebAppGenerator.Auth.Common Wasp.Generator.WebAppGenerator.Common - Wasp.Generator.WebAppGenerator.ExternalAuthG Wasp.Generator.WebAppGenerator.ExternalCodeGenerator Wasp.Generator.WebAppGenerator.OperationsGenerator Wasp.Generator.WebAppGenerator.OperationsGenerator.ResourcesG Wasp.Generator.WebAppGenerator.RouterGenerator Wasp.Generator.WebAppGenerator.Setup Wasp.Generator.WebAppGenerator.Start + Wasp.Generator.WebAppGenerator.Test Wasp.Generator.WriteFileDrafts - Wasp.Lib + Wasp.Project + Wasp.Project.Analyze + Wasp.Project.Common + Wasp.Project.Db + Wasp.Project.Db.Migrations + Wasp.Project.Db.Dev + Wasp.Project.Db.Dev.Postgres + Wasp.Project.Deployment + Wasp.Project.Env Wasp.NpmDependency Wasp.Psl.Ast.Model Wasp.Psl.Generator.Model Wasp.Psl.Parser.Model Wasp.SemanticVersion Wasp.Util + Wasp.Util.Network.Socket Wasp.Util.Control.Monad Wasp.Util.Fib Wasp.Util.IO @@ -336,6 +369,7 @@ library cli-lib , uuid , waspc , waspls + , unliftio ^>= 0.2.20 other-modules: Paths_waspc exposed-modules: Wasp.Cli.Command @@ -350,15 +384,20 @@ library cli-lib Wasp.Cli.Command.CreateNewProject Wasp.Cli.Command.Db Wasp.Cli.Command.Db.Migrate + Wasp.Cli.Command.Db.Reset + Wasp.Cli.Command.Db.Seed + Wasp.Cli.Command.Db.Studio Wasp.Cli.Command.Deps Wasp.Cli.Command.Deploy Wasp.Cli.Command.Dockerfile Wasp.Cli.Command.Info Wasp.Cli.Command.Start + Wasp.Cli.Command.Start.Db Wasp.Cli.Command.Telemetry Wasp.Cli.Command.Telemetry.Common Wasp.Cli.Command.Telemetry.Project Wasp.Cli.Command.Telemetry.User + Wasp.Cli.Command.Test Wasp.Cli.Command.Watch Wasp.Cli.Command.WaspLS Wasp.Cli.Common @@ -426,6 +465,7 @@ test-suite waspc-test Generator.DbGeneratorTest Generator.ExternalCodeGenerator.JsTest Generator.FileDraft.CopyFileDraftTest + Generator.FileDraft.CopyAndModifyTextFileDraftTest Generator.FileDraft.TemplateFileDraftTest Generator.MockWriteableMonad Generator.WebAppGeneratorTest @@ -474,6 +514,7 @@ test-suite e2e-test main-is: Main.hs build-tool-depends: waspc:wasp-cli build-depends: + , waspc , aeson , directory , base diff --git a/web/docs/_sendingEmailsInDevelopment.md b/web/docs/_sendingEmailsInDevelopment.md new file mode 100644 index 000000000..afb830fcf --- /dev/null +++ b/web/docs/_sendingEmailsInDevelopment.md @@ -0,0 +1,7 @@ +:::info Sending emails while developing + +When you run your app in development mode, the e-mails are not actually sent. Instead, they are logged to the console. + +In order to enable sending e-mails in development mode, you need to set the `SEND_EMAILS_IN_DEVELOPMENT` env variable to `true` in your `.env.server` file. + +::: diff --git a/web/docs/about.md b/web/docs/about.md deleted file mode 100644 index b0c4b5a6a..000000000 --- a/web/docs/about.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Introduction -sidebar_label: What is Wasp? -slug: /about ---- - -Thanks a lot for giving Wasp a try! In the sections below we will give a short overview of what it is, how it works and get you started. - -## What is Wasp? -Wasp is a programming language for building **full-stack web applications**. That means Wasp takes care of all three -major parts of a web application: **client** (front-end), **server** (back-end) and **deployment**. - -## Wasp is a DSL -Wasp is a programming language, but a specific kind: It is a *Domain Specific Language*, or shorter *DSL*. -That means it is not a general-purpose, Turing-complete language (such as e.g. Python or Java) and it is not meant -to replace them. Instead, it is specialised for a single purpose: **building modern web applications**. - -Other examples of *DSL*s that are often used today are e.g. *SQL* for databases and *HTML* for web page layouts. -The main advantage and reason why *DSL*s exist is that they need to do only one task (e.g. database queries) -so they can do it well and provide the best possible experience for the developer. - -The same idea stands behind Wasp - a language that will allow developers to **build modern web applications with -10x less code and less stack-specific knowledge**. - -## Wasp integrates with the existing stack -As mentioned above, Wasp is not trying to do everything at once but rather focuses on the accidental complexity -which arises from connecting all the parts of the stack (client, server, deployment) together. - -Right now, Wasp supports React and Node and relies on them to define web components and server queries and -actions. - -## Is Wasp a web app framework? -Wasp is addressing the same core problems that typical web app frameworks are addressing, and it in big part [looks, swims and quacks](https://en.wikipedia.org/wiki/Duck_test) like a web app framework. - -On the other hand, Wasp does not match typical expectations of a web app framework: it is not a set of libraries, it is instead a programming language (DSL). - -## What it is meant for -- building full-stack web apps (like e.g. Airbnb or Asana) -- quickly starting a web app with industry best practices -- to be used alongside modern web dev stack (currently supported React and Node) - -## What it is not meant for -- building static/presentational websites -- to be used as a no-code solution -- to be a solve-it-all tool in a single language diff --git a/web/docs/cli.md b/web/docs/cli.md index 4f089fa48..82186115a 100644 --- a/web/docs/cli.md +++ b/web/docs/cli.md @@ -21,14 +21,16 @@ COMMANDS uninstall Removes Wasp from your system. IN PROJECT start Runs Wasp app in development mode, watching for file changes. + start db Starts managed development database for you. db [args] Executes a database command. Run 'wasp db' for more info. - clean Deletes all generated code and other cached artifacts. Wasp equivalent of 'have you tried closing and opening it again?'. + clean Deletes all generated code and other cached artifacts. Wasp equivalent of 'have you tried closing and opening it again?'. build Generates full web app code, ready for deployment. Use when deploying or ejecting. deploy Deploys your Wasp app to cloud hosting providers. telemetry Prints telemetry status. deps Prints the dependencies that Wasp uses in your project. dockerfile Prints the contents of the Wasp generated Dockerfile. info Prints basic information about current Wasp project. + test Executes tests in your project. EXAMPLES wasp new MyApp @@ -80,7 +82,7 @@ To setup Bash completion, execute `wasp completion` and follow the instructions. ### In project - `wasp start` runs Wasp app in development mode. It opens a browser tab with your application running, and watches for any changes to .wasp or files in `src/` to automatically reflect in the browser. It also shows messages from the web app, the server and the database on stdout/stderr. - + - `wasp start db` starts the database for you. This can be very handy, since you don't need to spin up your own database or provide its connection URL to the Wasp app! - `wasp clean` deletes all generated code and other cached artifacts. If using SQlite, it also deletes the SQlite database. It is the Wasp equivalent to "try shutting it down and turning back on". ``` diff --git a/web/docs/guides/auth-ui.md b/web/docs/guides/auth-ui.md new file mode 100644 index 000000000..9a2bd9e7e --- /dev/null +++ b/web/docs/guides/auth-ui.md @@ -0,0 +1,217 @@ +--- +title: Auth UI +--- + +# Auth UI + +![Auth UI](/img/authui/all_screens.gif) + +## Usage + +### The UI changes dynamically as you update the config + +Based on your `main.wasp` file on the authentication providers you enabled, the Auth UI will show the corresponding buttons. + +For example, if you only enabled e-mail authentication: + +```c title="main.wasp" +app MyApp { + title: "My app", + //... + auth: { + methods: { + email: {}, + }, + // ... + } +} +``` + +We'll get this: + +![Auth UI](/img/authui/login.png) + +And then we enable Google and Github: + +```c title="main.wasp" {7-8} +app MyApp { + title: "My app", + //... + auth: { + methods: { + email: {}, + google: {}, + github: {}, + }, + // ... + } +} +``` + +The form will automatically update itself to look like this: + +![Auth UI](/img/authui/multiple_providers.png) + + + +## Available components + +Let's take a look at the components that are available for you to use. + +### Login form + +Useful for username & password and email authentication. + +![Login form](/img/authui/login.png) + +You can use the `LoginForm` component to build your own login form. + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Use it like this + +``` + +### Signup form + +Useful for username & password and email authentication. + +![Signup form](/img/authui/signup.png) + +You can use the `SignupForm` component to build your signup form. + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' + +// Use it like this + +``` + +### Forgot password form + +Useful for email authentication. + +![Forgot password form](/img/authui/forgot_password.png) + +You can use the `ForgotPasswordForm` component to build your own forgot password form. + +```tsx title="client/ForgotPasswordPage.tsx" +import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword' + +// Use it like this + +``` + +### Reset password form + +Useful for email authentication. + +![Reset password form](/img/authui/reset_password.png) + +You can use the `ResetPasswordForm` component to build your reset password form. + +```tsx title="client/ResetPasswordPage.tsx" +import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword' + +// Use it like this + +``` + +### Verify email form + +Useful for email authentication. + +![Verify email form](/img/authui/email_verification.png) + +You can use the `VerifyEmailForm` component to build your own verify email form. + +```tsx title="client/VerifyEmailPage.tsx" +import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail' + +// Use it like this + +``` + +## Customization + +You customize all of the available forms by passing props to them. + +Props you can pass to all of the forms: +- `appearance` - appearance of the form, see below (optional) +- `logo` - path to your logo (optional) +- `socialLayout` - layout of the social buttons, which can be `vertical`` or `horizontal` (optional) + +### Theme colors override + +We used [Stitches](https://stitches.dev/) to style the Auth UI. You can customize the styles by overriding the default theme tokens. + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Define your appearance +const appearance = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} + +// Use it like this + +``` + +See the list of all available tokens [here](https://github.com/wasp-lang/wasp/blob/main/waspc/data/Generator/templates/react-app/src/stitches.config.js). We'll be adding more tokens soon ๐Ÿ™‚ + +### Adding your logo + +You can add your logo to the Auth UI by passing the `logo` prop to any of the components. + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Use it like this + +``` + +### Social buttons layout + +You can change the layout of the social buttons by passing the `socialLayout` prop to any of the components. + +If we pass in `vertical`: + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Use it like this + +``` + +We get this: + +![Vertical social buttons](/img/authui/vertical_social_buttons.png) + +### Example of a custom login form + +If we provide the logo and custom colors: +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +import { appearance } from './appearance' +import todoLogo from '../../todoLogo.png' + +// Use it like this + +``` + +We get this: + +
    + Custom login form +
    diff --git a/web/docs/guides/email-auth.md b/web/docs/guides/email-auth.md new file mode 100644 index 000000000..0f881177d --- /dev/null +++ b/web/docs/guides/email-auth.md @@ -0,0 +1,430 @@ +--- +title: Email Authentication +--- + +# Email Authentication + +## Overview + +Wasp supports e-mail authentication out of the box, along with email verification and "forgot your password?" flows. It provides a set of routes and email templates that you can use to implement it in your app. + +![Auth UI](/img/authui/all_screens.gif) + +In this guide, we'll go through the easiest way to set up email authentication: using Wasp's [Auth UI](/docs/guides/auth-ui) components. Check out the Auth UI guide for more details on how to customize the UI. + +## Outline of the guide + +We'll need to take the following steps to set up email authentication: +- [ ] Set up email authentication in `main.wasp` +- [ ] Add the user entity +- [ ] Add the routes and pages +- [ ] Set up the email sender in `main.wasp` and `.env.server` +- [ ] Use Auth UI components in our pages + +Outline of the Wasp file we'll be working with: + +```c title="main.wasp" +// Configuring e-mail authentication +app myApp { ... } + +// Defining User entity +entity User { ... } + +// Defining routes and pages +route SignupRoute { ... } +page SignupPage { ... } +// ... +``` + +### Email authentication in `main.wasp` + +Let's first set up the email authentication by adding the following to our `main.wasp` file: + +```c title="main.wasp" +app myApp { + wasp: { + version: "^0.10.0" + }, + title: "My App", + auth: { + // 1. Specify the user entity + userEntity: User, + methods: { + // 2. Enable email authentication + email: { + // 3. Specify the email from field + fromField: { + name: "My App Postman", + email: "hello@itsme.com" + }, + // 4. Specify the email verification and password reset options + emailVerification: { + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + allowUnverifiedLogin: false, + }, + passwordReset: { + clientRoute: PasswordResetRoute, + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", + }, + }, + }, + onAuthFailedRedirectTo: "/login", + onAuthSucceededRedirectTo: "/profile" + }, +} +``` + +### User entity + +Then we'll define the `User` entity in our `main.wasp` file: + +```c title="main.wasp" {4-8} +// 5. Define the user entity +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + // Add your own fields below + // ... +psl=} +``` + +### Routes and pages + +Next, we need to define the routes and pages for the authentication pages. We'll show the React code later, but for now we'll just define the routes and pages. + +We'll add the following to our `main.wasp` file: + +```c title="main.wasp" +// 6. Define the routes +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@client/pages/auth/Signup.tsx" +} + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth/Login.tsx" +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { RequestPasswordReset } from "@client/pages/auth/RequestPasswordReset.tsx", +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/pages/auth/PasswordReset.tsx", +} + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/pages/auth/EmailVerification.tsx", +} +``` + +### Email sender + +We'll use SendGrid in this guide to send our e-mails. You can use any of the supported email providers. Read more about setting up the email sender in the [email sender setup guide](/docs/guides/sending-emails). + +To set up SendGrid to send emails, we will add the following to our `main.wasp` file: + +```c title="main.wasp" +app myApp { + ... + emailSender: { + provider: SendGrid, + } +} +``` + +... and add the following to our `.env.server` file: + +```c title=".env.server" +SENDGRID_API_KEY= +``` + +## Using Auth UI + +:::info +We are using [Tailwind CSS](https://tailwindcss.com/) to style the page. Read more about how to add it [here](/docs/integrations/css-frameworks#tailwind). +::: + +### Signup page + +![Auth UI](/img/authui/signup.png) + +We are using the `SignupForm` component from `@wasp/auth/forms/Signup` to render the signup form. + +```tsx title="client/pages/auth/Signup.tsx" +import { Link } from 'react-router-dom' +import { SignupForm } from '@wasp/auth/forms/Signup' + +export function Signup () { + return ( +
    +
    +
    +
    + +
    + + I already have an account (go to login). + +
    +
    +
    +
    +
    + ) +} +``` + +### Login page + +![Auth UI](/img/authui/login.png) + +We are using the `LoginForm` component from `@wasp/auth/forms/Login` to render the login form. + +```tsx title="client/pages/auth/Login.tsx" +import { Link } from 'react-router-dom' +import { LoginForm } from '@wasp/auth/forms/Login' + +export function Login() { + return ( +
    +
    +
    +
    + +
    + + Don't have an account yet? go to signup. + +
    + + Forgot your password?{' '} + reset it. + +
    +
    +
    +
    + ) +} +``` + +## Email verification setup + +By default, Wasp requires the e-mail to be verified before allowing the user to log in. This is done by sending a verification email to the user's email address and requiring the user to click on a link in the email to verify their email address. + +Our setup looks like this: + +```c title="main.wasp" +emailVerification: { + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + allowUnverifiedLogin: false, +} +``` + +When the user receives an e-mail, they receive a link that goes to the client route specified in the `clientRoute` field. In our case, this is the `EmailVerificationRoute` route we defined in the `main.wasp` file. + +```c title="main.wasp" +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/pages/auth/EmailVerification.tsx", +} +``` + +### Email verification page + +![Auth UI](/img/authui/email_verification.png) + +This route goes to the `EmailVerification` page, which is defined in the `EmailVerification.tsx` file: + +```jsx title="client/pages/auth/EmailVerification.tsx" +import { Link } from 'react-router-dom' +import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail' + +export function EmailVerification() { + return ( +
    +
    +
    +
    + +
    + + If everything is okay, go to login + +
    +
    +
    +
    + ) +} +``` + +You'll notice we are using the `VerifyEmailForm` component from the `@wasp/auth/forms/VerifyEmail` module. This will give a nice-looking form for the user to verify their e-mail. + +We will also override the default e-mail content. We are using the `getVerificationEmailContent` function from the `@server/auth/email.js` file to generate the email content. + +```ts title="server/auth/email.ts" +import { GetVerificationEmailContentFn } from '@wasp/types' + +export const getVerificationEmailContent: 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 + `, +}) +``` + +## Password reset setup + +Users can request a password and then they'll receive an e-mail with a link to reset their password. + +Our setup in `main.wasp` looks like this: + +```c title="main.wasp" +passwordReset: { + clientRoute: PasswordResetRoute, + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", +} +``` + +### Request password reset page + +![Request password reset page](/img/authui/forgot_password_after.png) + +They request their password to be reset by going to the `/request-password-reset` page. We'll add a link to this page in the login page. + +That page will look like this: + +```jsx title="client/pages/auth/RequestPasswordReset.tsx" +import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword' + +export function RequestPasswordReset() { + return ( +
    +
    +
    +
    + +
    +
    +
    +
    + ) +} +``` + +We will also override the default e-mail content that's sent. We are using the `getVerificationEmailContent` function from the `@server/auth/email.js` file to generate the email content. + +```ts title="server/auth/email.ts" +import { GetPasswordResetEmailContentFn } from '@wasp/types' + +export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ + passwordResetLink, +}) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, +}) +``` + +### Password reset page + +![Request password reset page](/img/authui/reset_password_after.png) + +When the user receives an e-mail, they receive a link that goes to the client route specified in the `clientRoute` field. In our case, this is the `PasswordResetRoute` route we defined in the `main.wasp` file. + +```c title="main.wasp" +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/pages/auth/PasswordReset.tsx", +} +``` + +This route goes to the `PasswordResetPage` page, which is defined in the `PasswordReset.tsx` file. Users can enter their new password here: + +```tsx title="client/pages/auth/PasswordReset.tsx" +import { Link } from 'react-router-dom' +import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword' + +export function PasswordReset() { + return ( +
    +
    +
    +
    + +
    + + If everything is okay, go to login + +
    +
    +
    +
    + ) +} +``` + +### Logout action + +To implement the logout action, you can use the `logout` function from the `@wasp/auth/logout` module. We can add it for example, to the `Navbar` component: + +```jsx title="client/components/Navbar.tsx" +import logout from '@wasp/auth/logout'; + +export function Navbar() { + return ( +
    + +
    + ) +} +``` + +### Getting the user in our client & server code + +You read about our `useAuth` hook in [this section](/docs/language/features#accessing-the-currently-logged-in-user) of the docs. + +In short, you can use the `useAuth` hook in your client code to get the currently logged-in user. If there is no user logged in, it will return `null`. + +```jsx title="client/pages/Profile.tsx" +import useAuth from '@wasp/auth' + +export function Profile() { + const { data: user } = useAuth() + + if (!user) { + return
    You are not logged in!
    + } + + return ( +
    + Hello, {user.email}! +
    + ) +} +``` + +## Conclusion + +And that's it! We now have a full authentication system in our app. We can register new users, login, logout, verify their e-mail, and reset their password. + +We hope you enjoyed this guide and that you learned something new. If you have any questions, feel free to ask them on [our Discord server](https://discord.gg/rzdnErX). \ No newline at end of file diff --git a/web/docs/guides/sending-emails.md b/web/docs/guides/sending-emails.md new file mode 100644 index 000000000..eb7002c45 --- /dev/null +++ b/web/docs/guides/sending-emails.md @@ -0,0 +1,147 @@ +--- +title: Sending Emails +--- + +import SendingEmailsInDevelopment from '../_sendingEmailsInDevelopment.md' + +# Sending Emails + +With Wasp's email-sending feature, you can easily integrate email functionality into your web application. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + +Choose from one of the providers: +- `Mailgun`, +- `SendGrid` +- or the good old `SMTP`. + +Optionally, define the `defaultFrom` field, so you don't need to provide it whenever sending an e-mail. + +## Sending e-mails + + + +Before jumping into details about setting up various providers, let's see how easy it is to send e-mails. + +You import the `emailSender` that is provided by the `@wasp/email` module and call the `send` method on it. + +```ts title="src/actions/sendEmail.js" +import { emailSender } from '@wasp/email/index.js' + +// In some action handler... +const info = await emailSender.send({ + from: { + name: 'John Doe', + email: 'john@doe.com', + }, + to: 'user@domain.com', + subject: 'Saying hello', + text: 'Hello world', + html: 'Hello world' +}) +``` + +Let's see what the `send` method accepts: + +- `from` - the sender's details. + - `name` - the name of the sender + - `email` - the e-mail address of the sender + - If you set up `defaultFrom` field in the `main.wasp`, this field is optional. +- `to` - the recipient's e-mail address +- `subject` - the subject of the e-mail +- `text` - the text version of the e-mail +- `html` - the HTML version of the e-mail + +The `send` method returns an object with the status of the sent e-mail. It varies depending on the provider you use. + +## Providers + +For each provider, you'll need to set up env variables in the `.env.server` file at the root of your project. + +## Using the SMTP provider + +First, set the provider to `SMTP` in your `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: SMTP, + } +} +``` + +Then, add the following env variables to your `.env.server` file. + +```properties title=".env.server" +SMTP_HOST= +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_PORT= +``` + +Many transactional email providers (e.g. Mailgun, SendGrid but also others) can also use SMTP, so you can use them as well. + +## Using the Mailgun provider + +Set the provider to `Mailgun` in the `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: Mailgun, + } +} +``` + +Then, get the Mailgun API key and domain and add them to your `.env.server` file. + +### Getting the API key and domain + +1. Go to [Mailgun](https://www.mailgun.com/) and create an account. +2. Go to [API Keys](https://app.mailgun.com/app/account/security/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. +4. Go to [Domains](https://app.mailgun.com/app/domains) and create a new domain. +5. Copy the domain and add it to your `.env.server` file. + +```properties title=".env.server" +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +``` + +## Using the SendGrid provider + +Set the provider field to `SendGrid` in your `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: SendGrid, + } +} +``` + +Then, get the SendGrid API key and add it to your `.env.server` file. + +### Getting the API key + +1. Go to [SendGrid](https://sendgrid.com/) and create an account. +2. Go to [API Keys](https://app.sendgrid.com/settings/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. + +```properties title=".env.server" +SENDGRID_API_KEY= +``` diff --git a/web/docs/guides/testing.md b/web/docs/guides/testing.md new file mode 100644 index 000000000..fe3ca7780 --- /dev/null +++ b/web/docs/guides/testing.md @@ -0,0 +1,110 @@ +--- +title: Testing +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; + +:::info +Wasp is in beta, so keep in mind there might be some kinks / bugs, and possibly some changes with testing support in the future. +If you encounter any issues, reach out to us on [Discord](https://discord.gg/rzdnErX) and we will make sure to help you out! +::: + +# Testing your React app + +Wasp enables you to quickly and easily write both unit tests and React component tests for your frontend code. Because we already use [Vite](https://vitejs.dev/), we also support testing the web app by giving you an integrated [Vitest](https://vitest.dev/) experience out of the box. + +
    + Included Libraries +
    + + [`vitest`](https://www.npmjs.com/package/vitest): Unit test framework with native Vite support. + + [`@vitest/ui`](https://www.npmjs.com/package/@vitest/ui): A nice UI for seeing your test results. + + [`jsdom`](https://www.npmjs.com/package/jsdom): A web browser test environment for Node.js. + + [`@testing-library/react`](https://www.npmjs.com/package/@testing-library/react) / [`@testing-library/jest-dom`](https://www.npmjs.com/package/@testing-library/jest-dom): Testing helpers. + + [`msw`](https://www.npmjs.com/package/msw): A server mocking library. + +
    +
    + +## Test File Structure + +Unit tests should live under your `src/client` directory and have an extension that is compatible with [these glob pattern defaults](https://vitest.dev/config/#include). Some of the files Vitest would pick up automatically: `yourFile.test.ts` or `YourComponent.test.jsx`. + +Within test files, you can import things to test using relative paths. For example, if you made a component called `Counter.jsx`, now you can test it by adding a file alongside it called `Counter.test.jsx`, which would just import from `'./Counter'`. + +## Running Tests + +Running `wasp test client` will execute Vitest in watch mode, and watch your Wasp source tree for any changes to compile as well. + +- If you want to see a live-updating UI, you can pass a `--ui` option, like so: `wasp test client --ui` +- If you'd like to just run the tests once and exit (for example, in CI), you can pass `run`, like so: `wasp test client run` + +In fact, anything after `wasp test client` gets passed to Vitest directly, so check out [their CLI docs](https://vitest.dev/guide/cli.html) for more. + +:::warning +You should not run `wasp test` while running `wasp start`, as both will attempt to compile and write your project to `.wasp/out`. +::: + +## Examples +### Unit Tests + +```ts title=src/client/Todo.test.ts +import { test, expect } from 'vitest' + +import { areThereAnyTasks } from './Todo' + +test('areThereAnyTasks', () => { + expect(areThereAnyTasks([])).toBe(false) +}) +``` + +### React Component Tests + +```ts title=src/client/Todo.test.tsx +import { test, expect } from 'vitest' +import { screen } from '@testing-library/react' + +import { mockServer, renderInContext } from '@wasp/test' +import getTasks from '@wasp/queries/getTasks' +import Todo from './Todo' + +const { mockQuery } = mockServer() + +const mockTasks = [{ + id: 1, + description: 'test todo 1', + isDone: true, + userId: 1 +}] + +test('handles mock data', async () => { + mockQuery(getTasks, mockTasks) + + renderInContext() + + await screen.findByText('test todo 1') + + expect(screen.getByRole('checkbox')).toBeChecked() + + screen.debug() +}) +``` + +#### React Testing Helpers + +Wasp provides two React testing helpers: +- `mockQuery`: Takes a Wasp Query to mock and the JSON data to return. + - This is helpful if your Query uses `useQuery`. + - Behind the scenes, this uses [`msw`](https://www.npmjs.com/package/msw) to create a server request handler that responds with the provided JSON to an HTTP request for the operation's endpoint. + - Request handlers are cleared after each test. +- `mockApi`: Similar to `mockQuery`, but for mocking `api`s instead. + - Instead of a Wasp Query, it takes a route of the shape: `mockApi({ method: HttpMethod.Get, path: '/foo/bar' }, { res: 'hello' })`. + - You can import `HttpMethod` like so: `import { HttpMethod } from '@wasp/types'`. +- `renderInContext`: Takes a React component, wraps it inside a `QueryClientProvider` and `Router`, and renders it. + +# Testing your server-side code + +Coming soon! diff --git a/web/docs/how-it-works.md b/web/docs/how-it-works.md deleted file mode 100644 index 3f94cd698..000000000 --- a/web/docs/how-it-works.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: How it works ---- - -At its core, Wasp works like any other language: input files written in the source, higher-level language are -fed into the compiler which then produces the code in a target, lower-level language. - -This is how it looks in the case of Wasp: -- **source**: `.wasp` files along with NodeJS functions and React components. -- **compiler**: `waspc`, a program written in Haskell -- **target**: a web application written in React and NodeJS - -Here is also a high-level diagram illustrating the described process: - -![Wasp compilation diagram](/img/wasp-compilation.png) diff --git a/web/docs/integrations/github.md b/web/docs/integrations/github.md index 0886b5261..b15e6ff46 100644 --- a/web/docs/integrations/github.md +++ b/web/docs/integrations/github.md @@ -29,8 +29,7 @@ app Example { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + // ... externalAuthAssociations SocialLogin[] psl=} diff --git a/web/docs/integrations/google.md b/web/docs/integrations/google.md index 3fefbc1f9..a8e866e01 100644 --- a/web/docs/integrations/google.md +++ b/web/docs/integrations/google.md @@ -29,8 +29,7 @@ app Example { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + // ... externalAuthAssociations SocialLogin[] psl=} diff --git a/web/docs/getting-started.md b/web/docs/introduction/getting-started.md similarity index 100% rename from web/docs/getting-started.md rename to web/docs/introduction/getting-started.md diff --git a/web/docs/introduction/what-is-wasp.md b/web/docs/introduction/what-is-wasp.md new file mode 100644 index 000000000..aeae9e085 --- /dev/null +++ b/web/docs/introduction/what-is-wasp.md @@ -0,0 +1,174 @@ +--- +title: What is Wasp? +--- + +import ImgWithCaption from '../../blog/components/ImgWithCaption' + +We will give a brief overview of what Wasp is, how it works on a high level and when to use it. + +## Wasp is a tool to build modern web applications + +It is an opinionated way of building **full-stack web applications**. It takes care of all three +major parts of a web application: **client** (front-end), **server** (back-end) and **deployment**. + +#### Works well with your existing stack +Wasp is not trying to do everything at once but rather focuses on the complexity +which arises from connecting all the parts of the stack (client, server, deployment) together. + +Wasp is using **React**, **Node.js** and **Prisma** under the hood and relies on them to define web components and server queries and actions. + +#### Wasp's secret sauce + +At the core is the Wasp compiler which takes the Wasp config and your Javascript code and outputs the client app, server app and deployment code. + + + + + +The cool thing about having a compiler that understands your code is that it can do a lot of things for you. + +Define your app in the Wasp config and get: +- login and signup with Auth UI components, +- full-stack type safety, +- e-mail sending, +- async processing jobs, +- React Query powered data fetching, +- security best practices, +- and more. + +You don't need to write any code for these features, Wasp will take care of it for you ๐Ÿคฏ And what's even better, Wasp also maintains the code for you, so you don't have to worry about keeping up with the latest security best practices. As Wasp updates, so does your app. + +### But what does it look like? + +Let's say you want to build a web app that allows users to **create and share their favorite recipes**. + +You would start by defining your app in the Wasp file: + +```c title="main.wasp" +app recepieApp { + title: "My Recipes", + wasp: { + version: "^0.10.0" + }, + auth: { + methods: { + google: {} // out-of-the-box auth with Google + }, + onAuthFailedRedirectTo: "/login", + onAuthSucceededRedirectTo: "/", + }, +} +``` + +Let's then add the data model for your recipes: + +```c title="main.wasp" +// Use Prisma schema syntax to define your data model +entity User {=psl + id Int @id @default(autoincrement()) + name String + recipes Recipe[] +psl=} + +entity Recipe {=psl + id Int @id @default(autoincrement()) + title String + description String? + userId Int + user User @relation(fields: [userId], references: [id]) +psl=} +``` + +Next, you would define some queries and actions... + +```c title="main.wasp" +// Queries have automatic cache invalidation and are type-safe +query getRecipes { + fn: import { getRecipes } from "@server/queries.js", + entities: [Recipe], +} + +// Actions are type-safe and can be used to perform side-effects +action addRecipe { + fn: import { addRecipe } from "@server/actions.js", + entities: [Recipe], +} +``` + +... which you would implement in your Javascript or Typescript code: + +```ts title="src/server/queries.ts" +// Wasp compiler will generate types for you based on your data model +import { GetRecipes } from "@wasp/queries/types"; +import { Recipe } from "@wasp/entities"; + +export const getRecipes: GetRecipes<{}, Recipe[]> = async (_args, context) => { + // Use Prisma to query your database + return context.entities.Recipe.findMany(); +}; +``` + +And then use it in your React component: + +```tsx title="src/client/pages/RecipeListPage.tsx" +import getRecipes from "@wasp/queries/getRecipes"; +import { useQuery } from "@wasp/queries"; + +export function Homepage({ user }: { user: User }) { + // Due to full-stack type safety, `recipes` will be of type `Recipe[]` here + const { data: recipes, isLoading } = useQuery(getRecipes); + + if (isLoading) { + return
    Loading...
    ; + } + + return ( +
    +

    Recipes

    +
      + {recipes.map((recipe) => ( +
    • + {recipe.title} +
    • + ))} +
    +
    + ); +} +``` + +And voila! We are listing all the recipes in our app ๐ŸŽ‰ + +This was just a quick example to give you a taste of what Wasp is. For step by step tour through the most important Wasp features, check out the [Todo app tutorial](/docs/tutorials/todo-app). + +## When to use Wasp +Wasp is addressing the same core problems that typical web app frameworks are addressing, and it in big part [looks, swims and quacks](https://en.wikipedia.org/wiki/Duck_test) like a web app framework. + +### Best used for +- building full-stack web apps (like e.g. Airbnb or Asana) +- quickly starting a web app with industry best practices +- to be used alongside modern web dev stack (currently supported React and Node) + +### Avoid using Wasp for +- building static/presentational websites +- to be used as a no-code solution +- to be a solve-it-all tool in a single language + +## Wasp is a DSL + +:::note +You don't need to know what a DSL is to use Wasp, but if you are curious, you can read more about it below. +::: + +Wasp does not match typical expectations of a web app framework: it is not a set of libraries, it is instead a programming language that understands your code and can do a lot of things for you. + +Wasp is a programming language, but a specific kind: it is specialized for a single purpose: **building modern web applications**. We call such languages *DSL*s (Domain Specific Language). + +Other examples of *DSL*s that are often used today are e.g. *SQL* for databases and *HTML* for web page layouts. +The main advantage and reason why *DSL*s exist is that they need to do only one task (e.g. database queries) +so they can do it well and provide the best possible experience for the developer. + +The same idea stands behind Wasp - a language that will allow developers to **build modern web applications with 10x less code and less stack-specific knowledge**. \ No newline at end of file diff --git a/web/docs/language/features.md b/web/docs/language/features.md index cfdbffc4f..8bbbaa442 100644 --- a/web/docs/language/features.md +++ b/web/docs/language/features.md @@ -4,6 +4,7 @@ title: Features import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +import SendingEmailsInDevelopment from '../_sendingEmailsInDevelopment.md' ## App @@ -58,6 +59,10 @@ Check [`app.db`](/docs/language/features#database-configuration) for more detail List of dependencies (external libraries). Check [`app.dependencies`](/docs/language/features#dependencies) for more details. +#### `emailSender: dict` (optional) +Email sender configuration. +Check [`app.emailSender`](/docs/language/features#email-sender) for more details. + ## Page `page` declaration is the top-level layout abstraction. Your app can have multiple pages. @@ -191,7 +196,15 @@ on top of it. The workflow is as follows: 3. Migration data is generated in `migrations/` folder (and should be commited). 4. Wasp developer uses Prisma JS API to work with the database when in Operations. -Currently entities can be accessed only in Operations (Queries & Actions), so check their part of docs for more info on how to use entities in their context. +#### Using Entities in Operations + +Most of the time in Wasp you will be working with entities in the context of Operations (Queries & Actions), so check their part of docs for more info on how to use entities in Operations. + +#### Using Entities directly + +If needed, you can also interact with entities directly via [Prisma Client(https://www.prisma.io/docs/concepts/components/prisma-client/crud) (although we recommend using them via injected `entities` when in Operations). + +To import Prisma Client in your Wasp server code, do `import prismaClient from '@wasp/dbClient'`. ## Queries and Actions (aka Operations) @@ -584,6 +597,134 @@ import { isPrismaError, prismaErrorToHttpError } from '@wasp/utils.js' } ``` +## APIs + +In Wasp, the default client-server interaction mechanism is through [Operations](#queries-and-actions-aka-operations). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`! Best of all, they should look and feel very familiar. + +### API + +APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations, and have no client-side helpers (like `useQuery`). + +To create a Wasp API, you must: +1. Define the APIs NodeJS implementation +2. Declare the API in Wasp using the `api` declaration + +After completing these two steps, you'll be able to call the API from client code (via our Axios wrapper), or from the outside world. + +:::note +In order to leverage the benefits of TypeScript and use types in your NodeJS implementation (step 1), you must add your `api` declarations to your `.wasp` file (step 2) _and_ compile the Wasp project. This will enable the Wasp compiler to generate any new types based on your `.wasp`file definitions for use in your implementation files. +::: + +#### Defining the APIs NodeJS implementation +An API should be implemented as a NodeJS function that takes three arguments. +1. `req`: Express Request object +2. `res`: Express Response object +3. `context`: An additional context object **injected into the API by Wasp**. This object contains user session information, as well as information about entities. The examples here won't use the context for simplicity purposes. You can read more about it in the [section about using entities in APIs](#using-entities-in-apis). + +##### Simple API example +```ts title="src/server/apis.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"}!` }) +} +``` + +##### More complicated TypeScript example +Let's say you wanted to create some `GET` route that would take an email address as a param, and provide them the answer to "Life, the Universe and Everything." :) What would this look like in TypeScript? + +```c title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar/:email") +} +``` + +```ts title="src/server/apis.ts" +import { FooBar } from '@wasp/apis/types' + +export const fooBar: FooBar< +{ email: string }, // params +{ answer: number } // response +> = (req, res, _context) => { + console.log(req.params.email) + res.json({ answer: 42 }) +} +``` + +#### Declaring an API in Wasp +After implementing your APIs in NodeJS, all that's left to do before using them is tell Wasp about it! +You can easily do this with the `api` declaration, which supports the following fields: +- `fn: ServerImport` (required) - The import statement of the APIs NodeJs implementation. +- `httpRoute: (HttpMethod, string)` (required) - The HTTP (method, path) pair, where the method can be one of: + - `ALL`, `GET`, `POST`, `PUT` or `DELETE` + - and path is an Express path `string`. +- `entities: [Entity]` (optional) - A list of entities you wish to use inside your API. +We'll leave this option aside for now. You can read more about it [here](#using-entities-in-apis). +- `auth: bool` (optional) - If auth is enabled, this will default to `true` and provide a `context.user` object. If you do not wish to attempt to parse the JWT in the Authorization Header, you may set this to `false`. + +Wasp APIs and their implementations don't need to (but can) have the same name. With that in mind, this is how you might declare the API that uses the implementations from the previous step: +```c title="pages/main.wasp" +// ... + +api fooBar { + fn: import { fooBar } from "@server/apis.js", + httpRoute: (GET, "/foo/bar") +} +``` + +#### Using the API +To use the API externally, you simply call the endpoint using the method and path you used. For example, if your app is running at `https://example.com` then from the above you could issue a `GET` to `https://example/com/foo/callback` (in your browser, Postman, `curl`, another web service, etc.). + +To use the API from your client, including with auth support, you can import the Axios wrapper from `@wasp/api` and invoke a call. For example: +```ts +import React, { useEffect } from 'react' +import api from '@wasp/api' + +async function fetchCustomRoute() { + const res = await api.get('/foo/bar') + console.log(res.data) +} + +export const Foo = () => { + useEffect(() => { + fetchCustomRoute() + }, []); + + return ( + <> + // ... + + ) +} +``` + +#### Using Entities in APIs +In many cases, resources used in APIs will be [Entities](#entity). +To use an Entity in your API, add it to the `api` declaration in Wasp: + +```c {3} title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar") +} +``` + +Wasp will inject the specified Entity into the APIs `context` argument, giving you access to the Entity's Prisma API: +```ts title="src/server/apis.ts" +import { FooBar } from '@wasp/apis/types' + +export const fooBar : FooBar = (req, res, context) => { + res.json({ count: await context.entities.Task.count() }) +} + +``` + +The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). + ## Jobs If you have server tasks that you do not want to handle as part of the normal request-response cycle, Wasp allows you to make that function a `job` and it will gain some "superpowers." Jobs will: @@ -612,11 +753,11 @@ Keep in mind that pg-boss jobs run alongside your other server-side code, so the
    pg-boss details - pg-boss provides many useful features, which can be found [here](https://github.com/timgit/pg-boss/blob/8.0.0/README.md). + pg-boss provides many useful features, which can be found [here](https://github.com/timgit/pg-boss/blob/8.4.2/README.md). When you add pg-boss to a Wasp project, it will automatically add a new schema to your database called `pgboss` with some internal tracking tables, including `job` and `schedule`. pg-boss tables have a `name` column in most tables that will correspond to your `job` identifier. Additionally, these tables maintain arguments, states, return values, retry information, start and expiration times, and other metadata required by pg-boss. - If you need to customize the creation of the pg-boss instance, you can set an environment variable called `PG_BOSS_NEW_OPTIONS` to a stringified JSON object containing [these initialization parameters](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#newoptions). **NOTE**: Setting this overwrites all Wasp defaults, so you must include database connection information as well. + If you need to customize the creation of the pg-boss instance, you can set an environment variable called `PG_BOSS_NEW_OPTIONS` to a stringified JSON object containing [these initialization parameters](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#newoptions). **NOTE**: Setting this overwrites all Wasp defaults, so you must include database connection information as well. ##### pg-boss considerations - Wasp starts pg-boss alongside your web server's application, where both are simultaneously operational. This means that jobs running via pg-boss and the rest of the server logic (like Operations) share the CPU, therefore you should avoid running CPU-intensive tasks via jobs. @@ -720,12 +861,12 @@ job mySpecialJob { Executor-specific default options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. These can be overridden during invocation with `submit()` or in a `schedule`. - ##### `pgBoss: JSON` (optional) - See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#sendname-data-options). + See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). #### `schedule: dict` (optional) - ##### `cron: string` (required) - A 5-placeholder format cron expression string. See rationale for minute-level precision [here](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#scheduling). + A 5-placeholder format cron expression string. See rationale for minute-level precision [here](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#scheduling). _If you need help building cron expressions, Check out_ [Crontab guru](https://crontab.guru/#0_*_*_*_*). @@ -736,7 +877,7 @@ job mySpecialJob { Executor-specific options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. The `perform.executorOptions` are the default options, and `schedule.executorOptions` can override/extend those. - ##### `pgBoss: JSON` (optional) - See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#sendname-data-options). + See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). #### `entities: [Entity]` (optional) A list of entities you wish to use inside your Job (similar to Queries and Actions). @@ -783,9 +924,9 @@ There will also be namespaced, job executor-specific objects. - For pg-boss, you may access: `pgBoss` - **NOTE**: no arguments are necessary, as we already applied the `jobId` in the available functions. - - `details()`: pg-boss specific job detail information. [Reference](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#getjobbyidid) - - `cancel()`: attempts to cancel a job. [Reference](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#cancelid) - - `resume()`: attempts to resume a canceled job. [Reference](https://github.com/timgit/pg-boss/blob/8.0.0/docs/readme.md#resumeid) + - `details()`: pg-boss specific job detail information. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#getjobbyidid) + - `cancel()`: attempts to cancel a job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#cancelid) + - `resume()`: attempts to resume a canceled job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#resumeid) ## Dependencies @@ -822,7 +963,8 @@ app MyApp { userEntity: User, externalAuthEntity: SocialLogin, methods: { - usernameAndPassword: {}, + usernameAndPassword: {}, // use this or email, not both + email: {}, // use this or usernameAndPassword, not both google: {}, gitHub: {}, }, @@ -836,16 +978,17 @@ app MyApp { `app.auth` is a dictionary with following fields: #### `userEntity: entity` (required) -Entity which represents the user (sometimes also referred to as *Principal*). +Entity which represents the user. #### `externalAuthEntity: entity` (optional) Entity which associates a user with some external authentication provider. We currently offer support for Google and GitHub. See the sections on [Social Login Providers](#social-login-providers-oauth-20) for more info. #### `methods: dict` (required) List of authentication methods that Wasp app supports. Currently supported methods are: -* `usernameAndPassword`: Provides support for authentication with a username and password. See [here](#username-and-password) for more. -* `google`: Provides support for login via Google accounts. See [here](#social-login-providers-oauth-20) for more. -* `gitHub`: Provides support for login via GitHub accounts. See [here](#social-login-providers-oauth-20) for more. +* `usernameAndPassword`: authentication with a username and password. See [here](#username-and-password) for more. +* `email`: authentication with a email and password. See [here](#email-authentication) for more. +* `google`: authentication via Google accounts. See [here](#social-login-providers-oauth-20) for more. +* `gitHub`: authentication via GitHub accounts. See [here](#social-login-providers-oauth-20) for more. #### `onAuthFailedRedirectTo: String` (required) Path where an unauthenticated user will be redirected to if they try to access a private page (which is declared by setting `authRequired: true` for a specific page). @@ -958,7 +1101,7 @@ Validations always run on `create()`, but only when the field mentioned in `vali #### Specification -### `login()` +#### `login()` An action for logging in the user. ```js login(username, password) @@ -980,7 +1123,7 @@ import login from '@wasp/auth/login.js' Login is a regular action and can be used directly from the frontend. -### `signup()` +#### `signup()` An action for signing up the user. This action does not log in the user, you still need to call `login()`. ```js signup(userFields) @@ -995,7 +1138,7 @@ import signup from '@wasp/auth/signup.js' Signup is a regular action and can be used directly from the frontend. -### `logout()` +#### `logout()` An action for logging out the user. ```js logout() @@ -1017,9 +1160,6 @@ const SignOut = () => { } ``` -#### Reset password -Coming soon. - #### Updating a user's password If you need to update user's password, you can do it safely via Prisma client, e.g. within an action: ```js @@ -1036,103 +1176,195 @@ You don't need to worry about hashing the password yourself - if you have an `au in your `.wasp` file, Wasp already set a middleware on Prisma that makes sure whenever password is created or updated on the user entity, it is also hashed before it is stored to the database. +### Email authentication -### Accessing the currently logged in user -When authentication is enabled in a Wasp app, we need a way to tell whether a user is logged in and access its data. -With that, we can further implement access control and decide which content is private and which public. +:::info +We have written a step-by-step guide on how to set up the e-mail authentication with Wasp's included Auth UI. -#### On the client -On the client, Wasp provides a React hook you can use in functional components - `useAuth`. -This hook is actually a thin wrapper over Wasp's [`useQuery` hook](http://localhost:3002/docs/language/features#the-usequery-hook) and returns data in the same format. +Read more in the [email authentication guide](/docs/guides/email-auth). +::: -### `useAuth()` -#### `import statement`: -```js -import useAuth from '@wasp/auth/useAuth.js' -``` +:::warning +If a user signs up with Google or Github (and you set it up to save their social provider e-mail info on the `User` entity), they'll be able to reset their password and login with e-mail and password. -##### Example of usage: -```js title="src/client/pages/MainPage.js" -import React from 'react' +If a user signs up with the e-mail and password and then tries to login with a social provider (Google or Github), they won't be able to do that. -import { Link } from 'react-router-dom' -import useAuth from '@wasp/auth/useAuth.js' -import logout from '@wasp/auth/logout.js' -import Todo from '../Todo.js' -import '../Main.css' +In the future, we will lift this limitation and enable smarter merging of accounts. +::: -const Main = () => { - const { data: user } = useAuth() +`email` authentication method makes it possible to signup/login into the app by using an e-mail and a password. - if (!user) { - return ( - - Please login or sign up. - - ) - } else { - return ( - <> - - - < /> - ) - } +```c title="main.wasp" +app MyApp { + title: "My app", + // ... + + auth: { + userEntity: User, + methods: { + email: { + // we'll deal with `email` below + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... } -export default Main +// Wasp requires the userEntity to have at least the following fields +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? +psl=} ``` -#### On the server +This method requires that `userEntity` specified in `auth` contains: -When authentication is enabled, all operations (actions and queries) will have access to the `user` through the `context` argument. `context.user` will contain all the fields from the user entity except for the password. +- optional `email` field of type `String` +- optional `password` field of type `String` +- `isEmailVerified` field of type `Boolean` with a default value of `false` +- optional `emailVerificationSentAt` field of type `DateTime` +- optional `passwordResetSentAt` field of type `DateTime` -##### Example of usage: -```js title="src/server/actions.js" -import HttpError from '@wasp/core/HttpError.js' +#### Fields in the `email` dict -export const createTask = async (task, context) => { - if (!context.user) { - throw new HttpError(403) - } +```c title="main.wasp" +app MyApp { + title: "My app", + // ... - const Task = context.entities.Task - return Task.create({ - data: { - description: task.description, - user: { - connect: { id: context.user.id } - } - } - }) + auth: { + userEntity: User, + methods: { + email: { + fromField: { + name: "My App", + email: "hello@itsme.com" + }, + emailVerification: { + allowUnverifiedLogin: false, + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + }, + passwordReset: { + clientRoute: PasswordResetRoute + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", + }, + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... } ``` -In order to implement access control, each operation is responsible for checking `context.user` and -acting accordingly - e.g. if `context.user` is `undefined` and the operation is private then user -should be denied access to it. -### Validation Error Handling -When creating, updating, or deleting entities, you may wish to handle validation errors. We have exposed a class called `AuthError` for this purpose. This could also be combined with [Prisma Error Helpers](/docs/language/features#prisma-error-helpers). +##### `fromField: EmailFromField` (required) +`fromField` is a dict that specifies the name and e-mail address of the sender of the e-mails sent by Wasp. It is required to be defined. The object has the following fields: +- `name`: name of the sender (optional) +- `email`: e-mail address of the sender -#### `import statement`: -```js -import AuthError from '@wasp/core/AuthError.js' +##### `emailVerification: EmailVerificationConfig` (required) +`emailVerification` is a dict that specifies the e-mail verification process. It is required to be defined. + +The object has the following fields: +- `clientRoute: Route`: a route that is used for the user to verify their e-mail address. (required) + +Client route should handle the process of taking a token from the URL and sending it to the server to verify the e-mail address. You can use our `verifyEmail` action for that. + +```js title="src/pages/EmailVerificationPage.jsx" +import { verifyEmail } from '@wasp/auth/email/actions'; +... +await verifyEmail({ token }); ``` -##### Example of usage: -```js - try { - await context.entities.User.update(...) - } catch (e) { - if (e instanceof AuthError) { - throw new HttpError(422, 'Validation failed', { message: e.message }) - } else { - throw e - } - } +Read on how to do it the easiest way with Auth UI in the [email authentication guide](/docs/guides/email-auth). + +- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. (optional) + +Defining `getEmailContentFn` can be done by defining a Javscript or Typescript file in the `server` directory. + +```ts title="server/email.ts" +import { GetVerificationEmailContentFn } from '@wasp/types' + +export const getVerificationEmailContent: 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 + `, +}) ``` -## Social Login Providers (OAuth 2.0) +- `allowUnverifiedLogin: Boolean`: a boolean that specifies whether the user can login without verifying their e-mail address. (optional) + +It defaults to `false`. If `allowUnverifiedLogin` is set to `true`, the user can login without verifying their e-mail address, otherwise users will receive a `401` error when trying to login without verifying their e-mail address. + +##### `passwordReset: PasswordResetConfig` (required) +`passwordReset` is a dict that specifies the password reset process. It is required to be defined. The object has the following fields: +- `clientRoute: Route`: a route that is used for the user to reset their password. (required) + +Client route should handle the process of taking a token from the URL and a new password from the user and sending it to the server. You can use our `requestPasswordReset` and `resetPassword` actions to do that. + +```js title="src/pages/ForgotPasswordPage.jsx" +import { requestPasswordReset } from '@wasp/auth/email/actions'; +... +await requestPasswordReset({ email }); +``` + +```js title="src/pages/PasswordResetPage.jsx" +import { resetPassword } from '@wasp/auth/email/actions'; +... +await resetPassword({ password, token }) +``` + + +Read on how to do it the easiest way with Auth UI in the [email authentication guide](/docs/guides/email-auth). + +- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. (optional) + +Defining `getEmailContentFn` is done by defining a function that looks like this: + +```ts title="server/email.ts" +import { GetPasswordResetEmailContentFn } from '@wasp/types' + +export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ + passwordResetLink, +}) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, +}) +``` + +#### Email sender for email authentication + +We require that you define an `emailSender`, so that Wasp knows how to send e-mails. Read more about that [here](#email-sender). + +#### Validations + +We provide basic validations out of the box. The validations are: +- `email`: non-empty, valid e-mail address +- `password`: non-empty, at least 8 characters, and contains a number + +Note that `email`s are stored in a case-insensitive manner. + +:::info +You don't need to worry about hashing the password yourself! Even when you are using Prisma's client directly and calling `create()` with a plain-text password, Wasp's middleware takes care of hashing it before storing it in the database. An additional middleware also performs field validation. +::: + + + +### Social Login Providers (OAuth 2.0) Wasp allows you to easily add social login providers to your app. The following is a list of links to guides that will help you get started with the currently supported providers: @@ -1144,7 +1376,7 @@ When using Social Login Providers, Wasp gives you the following options: - UI Helpers to make it easy to add social login buttons and actions - Override settings to customize the behavior of the providers -### Default Settings +#### Default Settings @@ -1192,7 +1424,7 @@ When using Social Login Providers, Wasp gives you the following options: -When a user signs in for the first time, Wasp assigns generated values to the `username` and `password` fields of the `userEntity` by default (e.g. `username: nice-blue-horse-14357`), so make sure to include these in your `userEntity` declaration even if you're only using a Social Login provider. If you'd like to change this behavior, these values can be overridden as described below. +When a user signs in for the first time, if the `userEntity` has `username` and/or `password` fields Wasp assigns generated values to those fields by default (e.g. `username: nice-blue-horse-14357` and a strong random `password`). This is a historical coupling between auth methods that will be removed over time. If you'd like to change this behavior, these values can be overridden as described below. :::tip Overriding Defaults It is also posslbe to [override the default](features#overrides-for-social-login-providers) login behaviors that Wasp provides for you. This allows you to create custom setups, such as allowing Users to define a username rather than the default random username assigned by Wasp on initial Login. @@ -1202,16 +1434,15 @@ It is also posslbe to [override the default](features#overrides-for-social-login Anytime an authentication method is used that relies on an external authorization provider, for example, Google, we require an `externalAuthEntity` specified in `auth`, in addition to the `userEntity`, that contains the following configuration: ```c {4,14} -... +//... auth: { userEntity: User, externalAuthEntity: SocialLogin, -... +//... entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + //... externalAuthAssociations SocialLogin[] psl=} @@ -1228,7 +1459,7 @@ psl=} :::note the same `externalAuthEntity` can be used across different social login providers (e.g., both GitHub and Google can use the same entity). ::: -### UI helpers +#### UI helpers Wasp provides sign-in buttons, logos and URLs for your login page: @@ -1256,9 +1487,9 @@ export default Login If you need more customization than what the buttons provide, you can create your own custom components using the `signInUrl`s. -### Overrides +#### Overrides -When a user signs in for the first time, Wasp will create a new User account and link it to the chosen Auth Provider account for future logins. The `username` will default to a random dictionary phrase that does not exist in the database, such as `nice-blue-horse-27160`. +When a user signs in for the first time, Wasp will create a new User account and link it to the chosen Auth Provider account for future logins. If the `userEntity` contains a `username` field it will default to a random dictionary phrase that does not exist in the database, such as `nice-blue-horse-27160`. This is a historical coupling between auth methods that will be removed over time. If you would like to allow the user to select their own username, or some other sign up flow, you could add a boolean property to your `User` entity indicating the account setup is incomplete. You can then check this user's property on the client with the [`useAuth()`](#useauth) hook and redirect them when appropriate - e.g. check on homepage if `user.isAuthSetup === false`, redirect them to `EditUserDetailsPage` where they can edit the `username` property. @@ -1374,6 +1605,103 @@ This function should return the user fields to use when creating a new user upon - `generateAvailableDictionaryUsername` generates a random dictionary phrase that is not yet in the database. For example, `nice-blue-horse-27160`. +### Validation Error Handling +When creating, updating, or deleting entities, you may wish to handle validation errors. We have exposed a class called `AuthError` for this purpose. This could also be combined with [Prisma Error Helpers](/docs/language/features#prisma-error-helpers). + +#### `import statement`: +```js +import AuthError from '@wasp/core/AuthError.js' +``` + +##### Example of usage: +```js + try { + await context.entities.User.update(...) + } catch (e) { + if (e instanceof AuthError) { + throw new HttpError(422, 'Validation failed', { message: e.message }) + } else { + throw e + } + } +``` + +## Accessing the currently logged in user +When authentication is enabled in a Wasp app, we need a way to tell whether a user is logged in and access its data. +With that, we can further implement access control and decide which content is private and which public. + +#### On the client +On the client, Wasp provides a React hook you can use in functional components - `useAuth`. +This hook is actually a thin wrapper over Wasp's [`useQuery` hook](http://localhost:3002/docs/language/features#the-usequery-hook) and returns data in the same format. + +### `useAuth()` +#### `import statement`: +```js +import useAuth from '@wasp/auth/useAuth' +``` + +##### Example of usage: +```js title="src/client/pages/MainPage.js" +import React from 'react' + +import { Link } from 'react-router-dom' +import useAuth from '@wasp/auth/useAuth' +import logout from '@wasp/auth/logout.js' +import Todo from '../Todo.js' +import '../Main.css' + +const Main = () => { + const { data: user } = useAuth() + + if (!user) { + return ( + + Please login or sign up. + + ) + } else { + return ( + <> + + + < /> + ) + } +} + +export default Main +``` + +#### On the server + +### `context.user` + +When authentication is enabled, all operations (actions and queries) will have access to the `user` through the `context` argument. `context.user` will contain all the fields from the user entity except for the password. + +##### Example of usage: +```js title="src/server/actions.js" +import HttpError from '@wasp/core/HttpError.js' + +export const createTask = async (task, context) => { + if (!context.user) { + throw new HttpError(403) + } + + const Task = context.entities.Task + return Task.create({ + data: { + description: task.description, + user: { + connect: { id: context.user.id } + } + } + }) +} +``` +In order to implement access control, each operation is responsible for checking `context.user` and +acting accordingly - e.g. if `context.user` is `undefined` and the operation is private then user +should be denied access to it. + ## Client configuration You can configure the client using the `client` field inside the `app` @@ -1500,7 +1828,7 @@ _react-query_'s `QueryClient` object: ```js title="src/client/myClientSetupCode.js" -import { configureQueryClient } from '@wasp/queries' +import { configureQueryClient } from '@wasp/queryClient' export default async function mySetupFunction() { // ... some setup @@ -1612,7 +1940,7 @@ Since environmental variables are usually different for server-side and client a `.env.server` and `.env.client` files should not be commited to the version control - we already ignore it by default in the .gitignore file we generate when you create a new Wasp project via `wasp new` cli command. -Variables are defined in `.env.server` or `env.client` files in the form of `NAME=VALUE`, for example: +Variables are defined in `.env.server` or `.env.client` files in the form of `NAME=VALUE`, for example: ``` DATABASE_URL=postgresql://localhost:5432 MY_VAR=somevalue @@ -1623,7 +1951,7 @@ Any env vars defined in the `.env.server` / `.env.client` files will be forwarde console.log(process.env.DATABASE_URL) ``` -## Database configuration +## Database Via `db` field of `app` declaration, you can configure the database used by Wasp. @@ -1632,46 +1960,180 @@ app MyApp { title: "My app", // ... db: { - system: PostgreSQL + system: PostgreSQL, + seeds: [ + import devSeed from "@server/dbSeeds.js" + ] } } ``` `app.db` is a dictionary with following fields: -#### `system: DbSystem` +#### - `system: DbSystem` (Optional) Database system that Wasp will use. It can be either `PostgreSQL` or `SQLite`. If not defined, or even if whole `db` field is not present, default value is `SQLite`. If you add/remove/modify `db` field, run `wasp db migrate-dev` to apply the changes. +#### - `seeds: [ServerImport]` (Optional) +Defines seed functions that you can use via `wasp db seed` to seed your database with initial data. +Check out [Seeding](#seeding) section for more details. + ### SQLite Default database is `SQLite`, since it is great for getting started with a new project (needs no configuring), but it can be used only in development - once you want to deploy Wasp to production you will need to switch to `PostgreSQL` and stick with it. Check below for more details on how to migrate from SQLite to PostgreSQL. ### PostgreSQL -When using `PostgreSQL` as your database (`app: { db: { system: PostgreSQL } }`), you will need to spin up a postgres database on your own so it runs during development (when running `wasp start` or doing `wasp db ...` commands) and you will need to provide Wasp with `DATABASE_URL` environment variable that Wasp will use to connect to it. +When using `PostgreSQL` as your database (`app: { db: { system: PostgreSQL } }`), you will need to make sure you have a postgres database running during development (when running `wasp start` or doing `wasp db ...` commands). -One of the easiest ways to run a PostgreSQL database on your own is by spinning up [postgres docker](https://hub.docker.com/_/postgres) container when you need it with the following shell command: -``` -docker run \ - --rm \ - --publish 5432:5432 \ - -v my-app-data:/var/lib/postgresql/data \ - --env POSTGRES_PASSWORD=devpass1234 \ - postgres -``` +### Using Wasp provided dev database -:::note -The password you provide via `POSTGRES_PASSWORD` is relevant only for the first time when you run that docker command, when database is set up for the first time. Consequent runs will ignore the value of `POSTGRES_PASSWORD` and will just use the password that was initially set. This is just how postgres docker works. -::: +Wasp provides `wasp start db` command that starts the default dev db for you. -The easiest way to provide the needed `DATABASE_URL` environment variable is by adding the following line to the [.env.server](https://wasp-lang.dev/docs/language/features#env) file in the root dir of your Wasp project (if that file doesn't yet exist, create it): -``` -DATABASE_URL=postgresql://postgres:devpass1234@localhost:5432/postgres -``` +Your Wasp app will automatically connect to it once you have it running via `wasp start db`, no additional configuration is needed. This command relies on Docker being installed on your machine. + +### Connecting to existing database + +If instead of using `wasp start db` you would rather spin up your own dev database or connect to some external database, you will need to provide Wasp with `DATABASE_URL` environment variable that Wasp will use to connect to it. + +The easiest way to provide the needed `DATABASE_URL` environment variable is by adding it to the [.env.server](https://wasp-lang.dev/docs/language/features#env) file in the root dir of your Wasp project (if that file doesn't yet exist, create it). + +You can also set it per command by doing `DATABASE_URL= wasp ...` -> this can be useful if you want to run specific `wasp` command on a specific database. +Example: you could do `DATABASE_URL= wasp db seed myProdSeed` to seed data for a fresh staging or production database. ### Migrating from SQLite to PostgreSQL To run Wasp app in production, you will need to switch from `SQLite` to `PostgreSQL`. -1. Set `app.db.system` to `PostgreSQL` and set `DATABASE_URL` env var accordingly (as described [above](/docs/language/features#postgresql)). -2. Delete old migrations, since they are SQLite migrations and can't be used with PostgreSQL: `rm -r migrations/`. -3. Run `wasp db migrate-dev` to apply new changes and create new, initial migration. You will need to have your postgres database running while doing this (check [above](/docs/language/features#postgresql) for easy way to get it running). + +1. Set `app.db.system` to `PostgreSQL`. +3. Delete old migrations, since they are SQLite migrations and can't be used with PostgreSQL: `rm -r migrations/`. +3. Run `wasp start db` to start your new db running (or check instructions above if you prefer using your own db). Leave it running, since we need it for the next step. +4. In a different terminal, run `wasp db migrate-dev` to apply new changes and create new, initial migration. +5. That is it, you are all done! + +### Seeding + +**Database seeding** is a term for populating database with some initial data. + +Seeding is most commonly used for two following scenarios: + 1. To put development database into a state convenient for testing / playing with it. + 2. To initialize dev/staging/prod database with some essential data needed for it to be useful, + for example default currencies in a Currency table. + +#### Writing a seed function + +Wasp enables you to define multiple **seed functions** via `app.db.seeds`: + +```c +app MyApp { + // ... + db: { + // ... + seeds: [ + import { devSeedSimple } from "@server/dbSeeds.js", + import { prodSeed } from "@server/dbSeeds.js" + ] + } +} +``` + +Each seed function is expected to be an async function that takes one argument, `prismaClient`, which is a [Prisma Client](https://www.prisma.io/docs/concepts/components/prisma-client/crud) instance that you can use to interact with the database. +This is the same instance of Prisma Client that Wasp uses internally, so you e.g. get password hashing for free. + +Since a seed function is part of the server-side code, it can also import other server-side code, so you can and will normally want to import and use Actions to perform the seeding. + +Example of a seed function that imports an Action (+ a helper function to create a user): + +```js +import { createTask } from './actions.js' + +export const devSeedSimple = async (prismaClient) => { + const user = await createUser(prismaClient, { + username: "RiuTheDog", + password: "bark1234" + }) + + await createTask( + { description: "Chase the cat" }, + { user, entities: { Task: prismaClient.task } } + ) +} + +async function createUser (prismaClient, data) { + const { password, ...newUser } = await prismaClient.user.create({ data }) + return newUser +} +``` + +#### Running seed functions + + - `wasp db seed`: If you have just one seed function, it will run it. If you have multiple, it will interactively ask you to choose one to run. + + - `wasp db seed `: It will run the seed function with the specified name, where the name is the identifier you used in its `import` expression in the `app.db.seeds` list. Example: `wasp db seed devSeedSimple`. + +:::tip + Often you will want to call `wasp db seed` right after you ran `wasp db reset`: first you empty your database, then you fill it with some initial data. +::: + + +## Email sender + +#### `provider: EmailProvider` (required) + +We support multiple different providers for sending e-mails: `SMTP`, `SendGrid` and `Mailgun`. + +### SMTP + +SMTP e-mail sender uses your SMTP server to send e-mails. + +Read [our guide](/docs/guides/sending-emails#using-the-smtp-provider) for setting up SMTP for more details. + + +### SendGrid + +SendGrid is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. + +Check out [our guide](/docs/guides/sending-emails#using-the-sendgrid-provider) for setting up Sendgrid for more details. + +### Mailgun + +Mailgun is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. + +Check out [our guide](/docs/guides/sending-emails#using-the-mailgun-provider) for setting up Mailgun for more details. + +#### `defaultSender: EmailFromField` (optional) + +You can optionally provide a default sender info that will be used when you don't provide it explicitly when sending an e-mail. + +```c +app MyApp { + title: "My app", + // ... + emailSender: { + provider: SMTP, + defaultFrom: { + name: "Hello", + email: "hello@itsme.com" + }, + }, +} +``` + +After you set up the email sender, you can use it in your code to send e-mails. For example, you can send an e-mail when a user signs up, or when a user resets their password. + +### Sending e-mails + + + +To send an e-mail, you can use the `emailSender` that is provided by the `@wasp/email` module. + +```ts title="src/actions/sendEmail.js" +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' +}) +``` diff --git a/web/docs/language/syntax.md b/web/docs/language/syntax.md index d6ecde288..61218b86f 100644 --- a/web/docs/language/syntax.md +++ b/web/docs/language/syntax.md @@ -66,6 +66,7 @@ While fundamental types are here to be basic building blocks of a language, and - Domain types ([source of truth](https://github.com/wasp-lang/wasp/blob/main/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs)) - Declaration types - **action** + - **api** - **app** - **entity** - **job** @@ -74,6 +75,7 @@ While fundamental types are here to be basic building blocks of a language, and - **route** - Enum types - **DbSystem** + - **HttpMethod** - **JobExecutor** For more details about each of the domain types, both regarding their body types and what they mean, check the [Features](/language/features.md) section. diff --git a/web/docs/tutorials/dev-excuses-app.md b/web/docs/tutorials/dev-excuses-app.md index 329dc5856..5f270bc07 100644 --- a/web/docs/tutorials/dev-excuses-app.md +++ b/web/docs/tutorials/dev-excuses-app.md @@ -6,7 +6,7 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; import DiscordLink from '../../blog/components/DiscordLink'; :::info -Make sure you've set up Wasp! Check out [Getting Started](/getting-started.md) first for installation instructions, and then continue with the tutorial. In case of any issues - please, ping us on . +Make sure you've set up Wasp! Check out [Getting Started](/docs) first for installation instructions, and then continue with the tutorial. In case of any issues - please, ping us on . ::: Weโ€™ll build a web app to solve every developer's most common problem โ€“ finding an excuse to justify our messy work! We will start with a single config file that outlines the full-stack architecture of our app plus several dozen lines of code for our specific business logic. There's no faster way to do it, so we canโ€™t excuse ourselves from building it! diff --git a/web/docs/tutorials/dev-excuses-app/01-creating-the-project.md b/web/docs/tutorials/dev-excuses-app/01-creating-the-project.md index ac67ae1f8..9ee690efa 100644 --- a/web/docs/tutorials/dev-excuses-app/01-creating-the-project.md +++ b/web/docs/tutorials/dev-excuses-app/01-creating-the-project.md @@ -5,7 +5,7 @@ title: Creating the project import useBaseUrl from '@docusaurus/useBaseUrl'; -By now you've already learned [how to install Wasp and create a new project](/getting-started.md). So letโ€™s create a new web app appropriately named `ItWaspsOnMyMachine`. +By now you've already learned [how to install Wasp and create a new project](/docs). So letโ€™s create a new web app appropriately named `ItWaspsOnMyMachine`. ``` wasp new ItWaspsOnMyMachine diff --git a/web/docs/tutorials/todo-app.md b/web/docs/tutorials/todo-app.md index 11ff836c1..6b3618370 100644 --- a/web/docs/tutorials/todo-app.md +++ b/web/docs/tutorials/todo-app.md @@ -5,7 +5,7 @@ title: Introduction import useBaseUrl from '@docusaurus/useBaseUrl'; :::info -If you want to run Wasp locally and haven't yet set it up, check out [Getting Started](/getting-started.md) first for installation instructions, and then continue with the tutorial. +If you want to run Wasp locally and haven't yet set it up, check out [Getting Started](/docs) first for installation instructions, and then continue with the tutorial. ::: Let's build together a simple Todo App in Wasp! diff --git a/web/docs/tutorials/todo-app/01-creating-new-project.md b/web/docs/tutorials/todo-app/01-creating-new-project.md index 651b7265e..26ab42df8 100644 --- a/web/docs/tutorials/todo-app/01-creating-new-project.md +++ b/web/docs/tutorials/todo-app/01-creating-new-project.md @@ -88,7 +88,7 @@ Let's start with the `main.wasp` file, which introduces 3 new concepts: ```c title="main.wasp" app TodoApp { // Main declaration, defines a new web app. wasp: { - version: "^0.7.0" + version: "^0.10.0" }, title: "Todo app" // Used as a browser tab title. } @@ -97,10 +97,9 @@ route RootRoute { path: "/", to: MainPage } // Render page MainPage on url `/` ( page MainPage { // We specify that ReactJS implementation of our page can be found in - // `src/client/MainPage.jsx` (the extension is not important) as a default - // export (uses standard js import syntax). Use '@client' to reference files - // inside the src/client folder. - component: import Main from "@client/MainPage" + // `src/client/MainPage.jsx` as a default export (uses standard js import syntax). + // Use '@client' to reference files inside the src/client folder. + component: import Main from "@client/MainPage.jsx" } ``` @@ -132,8 +131,7 @@ and `waspLogo.png` from the `src/client/` folder (`src/shared` and `src/server` are already clean). Wasp needs the `tsconfig.json` and `vite-env.d.ts` for IDE support, so it's important to keep them. -Now that `src/client` contains only `tsconfig.json`, `vite-env.d.ts`, and -`MainPage.jsx` let's start by making the `MainPage` component much simpler: +Next, let's make the `MainPage` component much simpler: ```jsx title="src/client/MainPage.jsx" const MainPage = () => { diff --git a/web/docs/typescript.md b/web/docs/typescript.md index 85673e9fc..24a034caa 100644 --- a/web/docs/typescript.md +++ b/web/docs/typescript.md @@ -11,10 +11,11 @@ This document assumes you are familiar with TypeScript and primarily focuses on The document also assumes a basic understanding of core Wasp features (e.g., Queries, Actions, Entities). You can read more about these features in [our feature docs](https://wasp-lang.dev/docs/language/features). Besides allowing you to write your code in TypeScript, Wasp also supports: - - Importing and using Wasp Entity types (on both the server and the client). - - Automatically generated types for Queries and Actions. - - Type-safe generic hooks (`useQuery` and `useAction`) with the accompanying type inference. - - Type-safe optimistic update definitions. + +- Importing and using Wasp Entity types (on both the server and the client). +- Automatic full-stack type support for Queries and Actions - frontend types are automatically inferred from backend definitions. +- Type-safe generic hooks (`useQuery` and `useAction`) with the accompanying type inference. +- Type-safe optimistic update definitions. We'll dig into the details of each feature in the following sections. But first, let's see how you can introduce TypeScript to an existing Wasp project. @@ -25,12 +26,15 @@ Your editor may sometimes report type and import errors even while `wasp start` ::: ## Migrating your project to TypeScript + Wasp supports TypeScript out of the box! Our scaffolding already includes TypeScript, so migrating your project to TypeScript is as simple as changing file extensions and using the language. This approach allows you to gradually migrate your project on a file-by-file basis. ### Example + Let's first assume your Wasp file contains the following definitions: + ```c title=main.wasp entity Task {=psl id Int @id @default(autoincrement()) @@ -43,9 +47,10 @@ query getTaskInfo { entities: [Task] } ``` + Let's now assume that your `queries.js` file looks something like this: -```javascript title="queries.js" +```javascript title="src/server/queries.js" import HttpError from "@wasp/core/HttpError.js" function getInfoMessage(task) { @@ -63,15 +68,20 @@ export const getTaskInfo = async ({ id }, context) => { } ``` To migrate this file to TypeScript, all you have to do is: - 1. Change the filename from `queries.js` to `queries.ts`. - 2. Write some types. + +1. Change the filename from `queries.js` to `queries.ts`. +2. Write some types. Let's start by only providing a basic `getInfoMessage` function. We'll see how to properly type the rest of the file in the following sections. -```typescript title=queries.ts + +```typescript title=src/server/queries.ts import HttpError from "@wasp/core/HttpError.js" // highlight-next-line -function getInfoMessage(task: { isDone: boolean, description: string }): string { +function getInfoMessage(task: { + isDone: boolean + description: string +}): string { const isDoneText = task.isDone ? "is done" : "is not done" return `Task '${task.description}' is ${isDoneText}.` } @@ -85,10 +95,14 @@ export const getTaskInfo = async ({ id }, context) => { return getInfoMessage(task) } ``` + You don't need to change anything inside the `.wasp` file. :::caution + + Even when you use TypeScript, and your file is called `queries.ts`, you still need to import it using the `.js` extension: + ```c query getTaskInfo { fn: import { getTaskInfo } from "@server/queries.js", @@ -102,9 +116,10 @@ Read more about ES modules in TypeScript [here](https://www.typescriptlang.org/d ::: ## Entity Types + Instead of manually specifying the types for `isDone` and `description`, we can get them from the `Task` entity type. Wasp will generate types for all entities and let you import them from `"@wasp/entities"`: -```typescript title=queries.ts +```typescript title=src/server/queries.ts import HttpError from "@wasp/core/HttpError.js" // highlight-next-line import { Task } from "@wasp/entities" @@ -124,12 +139,14 @@ export const getTaskInfo = async ({ id }, context) => { return getInfoMessage(task) } ``` + By doing this, we've connected the argument type of the `getInfoMessage` function with the `Task` entity. This coupling removes duplication and ensures the function keeps the correct signature even if we change the entity. Of course, the function might throw type errors depending on how we change the entity, but that's precisely what we want! Don't worry about typing the query function for now. We'll see how to handle this in the next section. Entity types are also available on the client under the same import: -```tsx title=Main.jsx + +```tsx title=src/client/Main.jsx import { Task } from "@wasp/entities" export function ExamplePage() {} @@ -138,15 +155,17 @@ export function ExamplePage() {} description: "Some random task", isDone: false, } - return
    {task.description}
    ; + return
    {task.description}
    } ``` -The mentioned type safety mechanisms also apply here: Changing the task entity in our `.wasp` file changes the imported type, which might throw a type error and warn us that our task definition is outdated. +The mentioned type safety mechanisms also apply here: changing the task entity in our `.wasp` file changes the imported type, which might throw a type error and warn us that our task definition is outdated. ## Backend type support for Queries and Actions -Wasp automatically generates the appropriate types for all operations (i.e., Actions and Queries) you define inside your `.wasp` file. Assuming your `.wasp` file contains the following definition: + +Wasp automatically generates the appropriate types for all Operations (i.e., Actions and Queries) you define inside your `.wasp` file. Assuming your `.wasp` file contains the following definition: + ```c title=main.wasp // ... @@ -155,15 +174,17 @@ query GetTaskInfo { entities: [Task] } ``` + Wasp will generate a type called `GetTaskInfo`, which you can use to type the Query's implementation. By assigning the `GetTaskInfo` type to your function, you get the type information for its context. In this case, TypeScript will know the `context.entities` object must include the `Task` entity. If the Query had auth enabled, it would also know that `context` includes user information. `GetTaskInfo` can is a generic type that takes two (optional) type arguments: + 1. `Input` - The argument (i.e., payload) received by the query function. 2. `Output` - The query function's return type. Suppose you don't care about typing the Query's inputs and outputs. In that case, you can omit both type arguments, and TypeScript will infer the most general types (i.e., `never` for the input, `unknown` for the output.). -```typescript title=queries.ts +```typescript title=src/server/queries.ts import HttpError from "@wasp/core/HttpError.js" import { Task } from "@wasp/entities" // highlight-next-line @@ -194,118 +215,128 @@ export const getTaskInfo: GetTaskInfo, string> = async ({ id }, } ``` Everything described above applies to Actions as well. +:::tip + +If don't want to define a new type for the Query's return value, the new `satisfies` keyword will allow TypeScript to infer it automatically: +```typescript +const getFoo = ((_args, context) => { + const foos = context.entities.Foo.findMany() + return { + foos, + message: "Here are some foos!", + queriedAt: new Date(), + } +}) satisfies GetFoo +``` +From the snippet above, TypeScript knows: +1. The correct type for `context`. +2. The Query's return type is `{ foos: Foo[], message: string, queriedAt: Date }`. + +If you don't need the context, you can skip specifying the Query's type (and arguments): +```typescript +const getFoo = () => {{ name: 'Foo', date: new Date() }} +``` + +::: ## Frontend type support for Queries and Actions -Wasp will soon support automatic full-stack type safety ร  la tRPC. Until then, you can get static type checking by manually passing type arguments to `useQuery` and `useAction` hooks. -### Type support for the `useQuery` hook -To add type support to Queries on the frontend, you can use: -- Entity types imported from `"@wasp/entities"`. -- The generic hook `useQuery` (read more about this hook [here](/docs/language/features#the-useaction-hook)): - - `Input` - Use this type argument to specify the type for the **request's payload**. - - `Output` - Use this type argument to specify the type for the **resposne's payload**. - - `Error` - Use this type argument to specify the error the Query throws. +Wasp supports automatic full-stack type safety ร  la tRPC. You only need to define the Operation's type on the backend, and the frontend will automatically know how to call it. -Here's what a component that uses the Query the `getTaskInfo` might look like: -```tsx title="TaskInfo.tsx" -import { useQuery } from "@wasp/queries" -import getTaskInfo from "@wasp/queries/getTaskInfo" -import { Task } from "@wasp/entities" +### Frontend type support for Queries +The examples assume you've defined the Query `getTaskInfo` from the previous sections: -type TaskInfoPayload = Pick - -export const TaskInfo = () => { - const { - // TypeScript knows `taskInfo` is a `string | undefined` because of the - // second type argument. - data: taskInfo, - // TypeScript also knows `isError` is a `boolean` regardless of the - // specified type arguments. - isError, - // TypeScript knows `id` must be a `Task["id"]` (i.e., a number) because of - // the first type argument. - // highlight-next-line - } = useQuery(getTaskInfo, { id: 1 }) - - if (isError) { - return
    Error when fetching tasks
    +```typescript title="src/server/queries.ts" +export const getTaskInfo: GetTaskInfo, string> = + async ({ id }, context) => { + // ... } - - // TypeScript forces you to perform this check. - return taskInfo === undefined ?
    Waiting for info...
    :
    {taskInfo}
    -} ``` -The above example omits the type argument for the error because it doesn't need it. Here's an example that uses the `error`: -```tsx title="TaskInfo.tsx" -import { useQuery } from "@wasp/queries" -import getTaskInfo from "@wasp/queries/getTaskInfo" -import { Task } from "@wasp/entities" -type TaskInfoPayload = Pick +Wasp will use the type of `getTaskInfo` to infer the Query's types on the frontend: + +```tsx title="src/client/TaskInfo.tsx" +import { useQuery } from "@wasp/queries" +// Wasp knows the type of `getTaskInfo` thanks to your backend definition. // highlight-next-line -type TaskInfoError = { message: string } +import getTaskInfo from "@wasp/queries/getTaskInfo" export const TaskInfo = () => { const { + // TypeScript knows `taskInfo` is a `string | undefined` thanks to the + // backend definition. data: taskInfo, + // TypeScript also knows `isError` is a `boolean`. isError, - // TypeScript knows `error` is a `TaskInfoError` because of the third type - // argument. + // TypeScript knows `error` is of type `Error`. error, - // highlight-next-line - } = useQuery(getTaskInfo, { id: 1 }) + // TypeScript knows `id` must be a `Task["id"]` (i.e., a number) thanks to + // your backend definition. + // highlight-next-line + } = useQuery(getTaskInfo, { id: 1 }) if (isError) { - // highlight-next-line - return
    Error during fetching tasks: {error.message || ''}
    + return
    Error during fetching tasks: {error.message || "unknown"}
    } // TypeScript forces you to perform this check. - return taskInfo === undefined ?
    Waiting for info...
    :
    {taskInfo}
    + return taskInfo === undefined ? ( +
    Waiting for info...
    + ) : ( +
    {taskInfo}
    + ) } ``` -### Type support for the `useAction` hook -To add type support to Actions on the frontend, you can use: -- Entity types imported from `"@wasp/entities"`. -- The generic hook `useAction` (read more about this hook [here](/docs/language/features#the-useaction-hook)): - - `Input` - This type argument specifies the type for the **request's payload**. - - `Output` - This type argument specifies the type for the **response's payload**. - - `Error` - This type argument specifies the error the Query throws. +### Frontend type support for Actions + +Assuming the following action definition in your `.wasp` file -Assuming the following action definition in your `.wasp` file (and the corresponding implementation in `src/server/actions.js`): ```typescript title=main.wasp -// ... - action addTask { fn: import { addTask } from "@server/actions.js" entities: [Task] } ``` -Here's how you can use it: -```tsx title=AddTask.tsx + +And its corresponding implementation in `src/server/actions.ts`: + +```typescript title=src/server/actions.ts +import { AddTask } from "@wasp/actions/types" + +type TaskPayload = Pick + +const addTask: AddTask = async (args, context) => { + // ... +} +``` + +Here's how to use it on the frontend: +```tsx title=src/client/AddTask.tsx import { useAction } from "@wasp/actions" +// TypeScript knows `addTask` is a function that expects a value of type +// `TaskPayload` and returns a value of type `Promise`. import addTask from "@wasp/queries/addTask" -import { Task } from "@wasp/entities" const AddTask = ({ description }: Pick) => { - - // TypeScript knows `addTaskAction` is a function that expects a value of - // type `Pick and returns a value of type - // `Promise`. - const addTaskAction = useAction, Task>(addTask) - return (
    - - + +
    ) } + +``` +#### Type support for the `useAction` hook +Type inference also works if you decide to use the action via the `useAction` hook: +```typescript +// addTaskFn is of type (args: TaskPayload) => Task +const addTaskFn = useAction(addTask) ``` The `useAction` hook also includes support for optimistic updates. Read [the feature docs](/docs/language/features#the-useaction-hook) to understand more about optimistic updates and how to define them in Wasp. @@ -313,31 +344,49 @@ The `useAction` hook also includes support for optimistic updates. Read [the fea Here's an example that shows how you can use static type checking in their definitions (the example assumes an appropriate action defined in the `.wasp` file and implemented on the server): ```tsx title=Task.tsx -import { useQuery } from '@wasp/queries' -import { OptimisticUpdateDefinition, useAction } from '@wasp/actions' -import updateTaskIsDone from '@wasp/actions/updateTaskIsDone' +import { useQuery } from "@wasp/queries" +import { OptimisticUpdateDefinition, useAction } from "@wasp/actions" +import updateTaskIsDone from "@wasp/actions/updateTaskIsDone" type TaskPayload = Pick const Task = ({ taskId }: Pick) => { - const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { - optimisticUpdates: [ - { - getQuerySpecifier: () => [getTask, { id: taskId }], - // This query's cache should should never be empty - updateQuery: ({ isDone }, oldTask) => ({ ...oldTask!, isDone }), - // highlight-next-line - } as OptimisticUpdateDefinition, - { - getQuerySpecifier: () => [getTasks], - updateQuery: (updatedTask, oldTasks) => - oldTasks && oldTasks.map(task => - task.id === updatedTask.id ? { ...task, ...updatedTask } : task - ), - // highlight-next-line - } as OptimisticUpdateDefinition - ] - }) + const updateTaskIsDoneOptimistically = useAction( + updateTaskIsDone, + { + optimisticUpdates: [ + { + getQuerySpecifier: () => [getTask, { id: taskId }], + // This query's cache should should never be empty + updateQuery: ({ isDone }, oldTask) => ({ ...oldTask!, isDone }), + // highlight-next-line + } as OptimisticUpdateDefinition, + { + getQuerySpecifier: () => [getTasks], + updateQuery: (updatedTask, oldTasks) => + oldTasks && + oldTasks.map((task) => + task.id === updatedTask.id ? { ...task, ...updatedTask } : task + ), + // highlight-next-line + } as OptimisticUpdateDefinition, + ], + } + ) // ... } ``` + +## Database seeding + +When implementing a seed function in TypeScript, you can import a `DbSeedFn` type via + +```ts +import type { DbSeedFn } from "@wasp/dbSeed/types.js" +``` + +and use it to type your seed function like this: + +```ts +export const devSeedSimple: DbSeedFn = async (prismaClient) => { ... } +``` diff --git a/web/docusaurus.config.js b/web/docusaurus.config.js index d4bc605fe..620c27919 100644 --- a/web/docusaurus.config.js +++ b/web/docusaurus.config.js @@ -1,22 +1,23 @@ -const lightCodeTheme = require('prism-react-renderer/themes/github'); -const darkCodeTheme = require('prism-react-renderer/themes/dracula'); +const lightCodeTheme = require("prism-react-renderer/themes/github"); +const darkCodeTheme = require("prism-react-renderer/themes/dracula"); /** @type {import('@docusaurus/types').DocusaurusConfig} */ module.exports = { - title: 'Wasp', - tagline: 'A simple language for developing full-stack web apps with less code.', + title: "Wasp", + tagline: + "A simple language for developing full-stack web apps with less code.", // url, baseUrl, organizationName, projectName and trailingSlash are set according to the // instructions in https://docusaurus.io/docs/deployment#deploying-to-github-pages . - url: 'https://wasp-lang.dev', - baseUrl: '/', // Should be name of repo if hosted on Github Pages, but can be just '/' if custom domain is used. - organizationName: 'wasp-lang', // Should be GitHub org/user name if hosted on Github Pages. - projectName: 'wasp', // Should be repo name if hosted on Github Pages. + url: "https://wasp-lang.dev", + baseUrl: "/", // Should be name of repo if hosted on Github Pages, but can be just '/' if custom domain is used. + organizationName: "wasp-lang", // Should be GitHub org/user name if hosted on Github Pages. + projectName: "wasp", // Should be repo name if hosted on Github Pages. trailingSlash: false, - onBrokenLinks: 'throw', - onBrokenMarkdownLinks: 'warn', - favicon: 'img/favicon.ico', + onBrokenLinks: "throw", + onBrokenMarkdownLinks: "warn", + favicon: "img/favicon.ico", stylesheets: [ - 'https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap' + "https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap", ], themeConfig: { /* @@ -41,139 +42,132 @@ module.exports = { container: '#zoom-container', template: '#zoom-template', */ - } + }, }, navbar: { - title: '.wasp (beta)', + title: ".wasp (beta)", logo: { - alt: 'Wasp logo', - src: 'img/wasp-logo-eqpar-circle.png', + alt: "Wasp logo", + src: "img/wasp-logo-eqpar-circle.png", }, items: [ { - to: 'docs/', - activeBasePath: 'docs', - label: 'Docs', - position: 'left', - className: 'navbar-item-docs navbar-item-outside' + to: "docs/", + activeBasePath: "docs", + label: "Docs", + position: "left", + className: "navbar-item-docs navbar-item-outside", }, { - to: 'blog', - label: 'Blog', - position: 'left' + to: "blog", + label: "Blog", + position: "left", }, { - href: 'https://github.com/wasp-lang/wasp', - className: 'navbar-item-github', - position: 'right', + href: "https://github.com/wasp-lang/wasp", + className: "navbar-item-github", + position: "right", }, { - href: 'https://twitter.com/WaspLang', - className: 'navbar-item-twitter', - position: 'right', + href: "https://twitter.com/WaspLang", + className: "navbar-item-twitter", + position: "right", }, { - href: 'https://discord.gg/rzdnErX', - className: 'navbar-item-discord', - position: 'right', + href: "https://discord.gg/rzdnErX", + className: "navbar-item-discord", + position: "right", }, ], }, prism: { - additionalLanguages: ['shell-session', 'haskell'] + additionalLanguages: ["shell-session", "haskell"], }, footer: { - style: 'dark', + style: "dark", links: [ { - title: 'Docs', + title: "Docs", items: [ { - label: 'Getting started', - to: 'docs' + label: "Getting started", + to: "docs", }, { - label: 'Todo app tutorial', - to: 'docs/tutorials/todo-app' + label: "Todo app tutorial", + to: "docs/tutorials/todo-app", }, { - label: 'Reference', - to: 'docs/language/features' - } - ] + label: "Reference", + to: "docs/language/features", + }, + ], }, { - title: 'Community', + title: "Community", items: [ { - label: 'Discord', - href: 'https://discord.gg/rzdnErX' - } - ] + label: "Discord", + href: "https://discord.gg/rzdnErX", + }, + ], }, { - title: 'More', + title: "More", items: [ { - label: 'GitHub', - href: 'https://github.com/wasp-lang/wasp' + label: "GitHub", + href: "https://github.com/wasp-lang/wasp", }, { - label: 'Contact', - to: 'docs/contact' - } + label: "Contact", + to: "docs/contact", + }, ], }, ], copyright: `Copyright ยฉ ${new Date().getFullYear()} Wasp.`, }, algolia: { - appId: 'RG0JSZOWH4', - apiKey: 'feaa2a25dc596d40418c82cd040e2cbe', - indexName: 'wasp-lang', + appId: "RG0JSZOWH4", + apiKey: "feaa2a25dc596d40418c82cd040e2cbe", + indexName: "wasp-lang", // TODO: contextualSearch is useful when you are doing versioning, // it searches only in v1 docs if you are searching from v1 docs. // We should enable it if we start doing versioning. // contextualSearch: true - } + }, }, presets: [ [ - '@docusaurus/preset-classic', + "@docusaurus/preset-classic", { gtag: { - trackingID: 'GTM-PQ4JFCK', + trackingID: "GTM-PQ4JFCK", anonymizeIP: true, }, docs: { - sidebarPath: require.resolve('./sidebars.js'), + sidebarPath: require.resolve("./sidebars.js"), sidebarCollapsible: true, // Please change this to your repo. - editUrl: - 'https://github.com/wasp-lang/wasp/edit/main/web', + editUrl: "https://github.com/wasp-lang/wasp/edit/main/web", }, blog: { showReadingTime: true, // Please change this to your repo. - blogSidebarCount: 'ALL', - blogSidebarTitle: 'All our posts', - editUrl: - 'https://github.com/wasp-lang/wasp/edit/main/web/blog', + blogSidebarCount: "ALL", + blogSidebarTitle: "All our posts", + editUrl: "https://github.com/wasp-lang/wasp/edit/main/web/blog", }, theme: { - customCss: [ - require.resolve('./src/css/custom.css'), - ] + customCss: [require.resolve("./src/css/custom.css")], }, }, ], ], - scripts: [ - '/scripts/posthog.js', - '/js/fix-multiple-trailing-slashes.js' - ], + scripts: ["/scripts/posthog.js", "/js/fix-multiple-trailing-slashes.js"], plugins: [ - 'plugin-image-zoom', + "plugin-image-zoom", async function myPlugin(context, options) { return { name: "docusaurus-tailwindcss", diff --git a/web/package-lock.json b/web/package-lock.json index 5a16f13a6..65e2c539e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,8 +33,7 @@ "@docusaurus/module-type-aliases": "2.2.0" }, "engines": { - "node": "^18.12.0", - "npm": "^8.19.2" + "node": "^18.12.0" } }, "node_modules/@algolia/autocomplete-core": { diff --git a/web/package.json b/web/package.json index 1a243865a..cda6d09d2 100644 --- a/web/package.json +++ b/web/package.json @@ -39,8 +39,7 @@ "@docusaurus/module-type-aliases": "2.2.0" }, "engines": { - "node": "^18.12.0", - "npm": "^8.19.2" + "node": "^18.12.0" }, "browserslist": { "production": [ diff --git a/web/sidebars.js b/web/sidebars.js index 34a26e67f..04bc443da 100644 --- a/web/sidebars.js +++ b/web/sidebars.js @@ -1,87 +1,77 @@ module.exports = { docs: [ { - type: 'category', - label: 'Introduction', + type: "category", + label: "Introduction", collapsed: false, - items: [ - 'getting-started', - 'about', - 'how-it-works', - ] + items: ["introduction/getting-started", "introduction/what-is-wasp"], }, { - type: 'category', - label: 'Tutorials', + type: "category", + label: "Tutorials", collapsed: false, items: [ { - type: 'category', - label: 'Todo app', + type: "category", + label: "Todo app", collapsed: true, items: [ - 'tutorials/todo-app', + "tutorials/todo-app", { - type: 'category', - label: 'Basics', + type: "category", + label: "Basics", collapsed: true, items: [ - 'tutorials/todo-app/01-creating-new-project', - 'tutorials/todo-app/02-task-entity', - 'tutorials/todo-app/03-listing-tasks', - 'tutorials/todo-app/04-creating-tasks', - 'tutorials/todo-app/05-updating-tasks' - ] + "tutorials/todo-app/01-creating-new-project", + "tutorials/todo-app/02-task-entity", + "tutorials/todo-app/03-listing-tasks", + "tutorials/todo-app/04-creating-tasks", + "tutorials/todo-app/05-updating-tasks", + ], }, - 'tutorials/todo-app/06-auth', - 'tutorials/todo-app/07-dependencies', - 'tutorials/todo-app/08-the-end' - ] + "tutorials/todo-app/06-auth", + "tutorials/todo-app/07-dependencies", + "tutorials/todo-app/08-the-end", + ], }, - ] + ], }, - 'examples', - { - type: 'category', - label: 'Guides', + "examples", + { + type: "category", + label: "Guides", collapsed: false, items: [ + "guides/auth-ui", { - type: 'category', - label: 'Auth Providers', + type: "category", + label: "Auth Providers", collapsed: false, items: [ - 'integrations/github', - 'integrations/google', - ] + "integrations/github", + "integrations/google", + "guides/email-auth", + ], }, - 'integrations/css-frameworks', - 'deploying', - 'typescript', + "integrations/css-frameworks", + "deploying", + "typescript", + "guides/testing", + "guides/sending-emails", ], - }, { - type: 'category', - label: 'Language', + type: "category", + label: "Language", collapsed: false, - items: [ - 'language/overview', - 'language/syntax', - 'language/features' - ] + items: ["language/overview", "language/syntax", "language/features"], }, - 'cli', + "cli", { - type: 'category', - label: 'Other', + type: "category", + label: "Other", collapsed: true, - items: [ - 'contributing', - 'vision', - 'telemetry', - 'contact' - ] - } - ] -} + items: ["contributing", "vision", "telemetry", "contact"], + }, + ], +}; diff --git a/web/static/img/authui/all_screens.gif b/web/static/img/authui/all_screens.gif new file mode 100644 index 000000000..7e6ca2ae6 Binary files /dev/null and b/web/static/img/authui/all_screens.gif differ diff --git a/web/static/img/authui/custom_login.gif b/web/static/img/authui/custom_login.gif new file mode 100644 index 000000000..36f32a626 Binary files /dev/null and b/web/static/img/authui/custom_login.gif differ diff --git a/web/static/img/authui/email_verification.png b/web/static/img/authui/email_verification.png new file mode 100644 index 000000000..3ea48f634 Binary files /dev/null and b/web/static/img/authui/email_verification.png differ diff --git a/web/static/img/authui/forgot_password.png b/web/static/img/authui/forgot_password.png new file mode 100644 index 000000000..96cfc77c8 Binary files /dev/null and b/web/static/img/authui/forgot_password.png differ diff --git a/web/static/img/authui/forgot_password_after.png b/web/static/img/authui/forgot_password_after.png new file mode 100644 index 000000000..7b7459f94 Binary files /dev/null and b/web/static/img/authui/forgot_password_after.png differ diff --git a/web/static/img/authui/login.png b/web/static/img/authui/login.png new file mode 100644 index 000000000..9d6d342de Binary files /dev/null and b/web/static/img/authui/login.png differ diff --git a/web/static/img/authui/multiple_providers.png b/web/static/img/authui/multiple_providers.png new file mode 100644 index 000000000..4e35f7943 Binary files /dev/null and b/web/static/img/authui/multiple_providers.png differ diff --git a/web/static/img/authui/reset_password.png b/web/static/img/authui/reset_password.png new file mode 100644 index 000000000..c028c33a2 Binary files /dev/null and b/web/static/img/authui/reset_password.png differ diff --git a/web/static/img/authui/reset_password_after.png b/web/static/img/authui/reset_password_after.png new file mode 100644 index 000000000..47843ccf6 Binary files /dev/null and b/web/static/img/authui/reset_password_after.png differ diff --git a/web/static/img/authui/signup.png b/web/static/img/authui/signup.png new file mode 100644 index 000000000..a932260d0 Binary files /dev/null and b/web/static/img/authui/signup.png differ diff --git a/web/static/img/authui/signup_after.png b/web/static/img/authui/signup_after.png new file mode 100644 index 000000000..91f4d3b4a Binary files /dev/null and b/web/static/img/authui/signup_after.png differ diff --git a/web/static/img/authui/vertical_social_buttons.png b/web/static/img/authui/vertical_social_buttons.png new file mode 100644 index 000000000..bc539ee33 Binary files /dev/null and b/web/static/img/authui/vertical_social_buttons.png differ