diff --git a/README.md b/README.md index 233b24d..bc39dff 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,37 @@ A complete, serverless, full-stack application built on AWS Lambda, AWS HTTP API ## Quick Start -Run `npm i` in all subdirectories that contain `package.json` +Install the latest version of the Serverless Framework: -Add your AWS credentials in `.env` file in the root directory, like this: -```text -AWS_ACCESS_KEY_ID=JAFJ89109JASFKLJASF -AWS_SECRET_ACCESS_KEY=AJ91J9A0SFA0S9FSKAFLASJFLJ +``` +npm i -g serverless +``` + +After installation, make sure you connect your AWS account by setting a provider in the org setting page on the [Serverless Dashboard](https://app.serverless.com). + +Then, initialize the `fullstack-app` template: +``` +serverless init fullstack-app +cd fullstack-app +``` + +Then, add the following environment variables in an `.env` file in the root directory, like this: + +```text +# This signs you JWT tokens used for auth. Enter a random string in here that's ~40 characters in length. tokenSecret=yourSecretKey # Only add this if you want a custom domain. Purchase it on AWS Route53 in your target AWS account first. domain=serverless-fullstack-app.com ``` -In the root folder of the project, run `serverless deploy --all` +In the root folder of the project, run `serverless deploy` + +Lastly, you will need to add your API domain manually to your React application in `./site/src/config.js`, so that you interact with your serverless Express.js back-end. You can find the your API url by going into `./api` and running `serverless info` and copying the `url:` value. It should look something like this `https://9jfalnal19.execute-api.us-east-1.amazonaws.com` or it will look like the custom domain you have set. + +**Note:** Upon the first deployment of your website, it will take a 2-3 minutes for the Cloudfront (CDN) URL to work. Until then, you can access it via the `bucketUrl`. After initial deployment, we recommend deploying only the parts you are changing, not the entire thing together (why risk deploying your database with a code change?). To do this, `cd` into a part of the application and run `serverless deploy`. @@ -43,7 +59,6 @@ Support for stages is built in. You can deploy everything or individual components to different stages via the `--stage` flag, like this: `serverless deploy --stage prod` -`serverless deploy --all --stage prod` Or, you can hardcode the stage in `serverless.yml` (not recommended): @@ -77,5 +92,20 @@ And deploy! `serverless deploy --stage prod` - Enjoy! This is a work in progress and we will continue to add funcitonality to this. + +## Other Resources + +For more details on each part of this fullstack application, check out these resources: + +* [Serverless Components](https://github.com/serverless/components) +* [Serverless Express](https://github.com/serverless-components/express) +* [Serverless Website](https://github.com/serverless-components/website) +* [Serverless AWS DynamoDB](https://github.com/serverless-components/aws-dynamodb) +* [Serverless AWS IAM Role](https://github.com/serverless-components/aws-iam-role) + +## Guides + +### How To Debug CORS Errors + +If you are running into CORS errors, see our guide on debugging them [within the Express Component's repo](https://github.com/serverless-components/express/blob/master/README.md#how-to-debug-cors-errors) diff --git a/api/app.js b/api/app.js index f5c9c63..d478c04 100644 --- a/api/app.js +++ b/api/app.js @@ -5,15 +5,19 @@ const { users } = require('./controllers') -// Configure Passport -require('./config/passport')(passport) +/** + * Configure Passport + */ + +try { require('./config/passport')(passport) } +catch (error) { console.log(error) } /** * Configure Express.js Middleware */ // Enable CORS -app.use(function(req, res, next) { +app.use(function (req, res, next) { res.header('Access-Control-Allow-Origin', '*') res.header('Access-Control-Allow-Methods', '*') res.header('Access-Control-Allow-Headers', '*') @@ -28,24 +32,40 @@ app.use(passport.session()) // Enable JSON use app.use(express.json()) +// Since Express doesn't support error handling of promises out of the box, +// this handler enables that +const asyncHandler = fn => (req, res, next) => { + return Promise + .resolve(fn(req, res, next)) + .catch(next); +}; + /** - * Routes + * Routes - Public */ app.options(`*`, (req, res) => { res.status(200).send() }) -app.post(`/users/register`, users.register) - -app.post(`/users/login`, users.login) +app.post(`/users/register`, asyncHandler(users.register)) -app.post(`/user`, passport.authenticate('jwt', { session: false }), users.get) +app.post(`/users/login`, asyncHandler(users.login)) -app.get(`/test`, (req, res) => { +app.get(`/test/`, (req, res) => { res.status(200).send('Request received') }) +/** + * Routes - Protected + */ + +app.post(`/user`, passport.authenticate('jwt', { session: false }), asyncHandler(users.get)) + +/** + * Routes - Catch-All + */ + app.get(`/*`, (req, res) => { res.status(404).send('Route not found') }) @@ -55,7 +75,7 @@ app.get(`/*`, (req, res) => { */ app.use(function (err, req, res, next) { console.error(err) - res.status(500).send('Internal Serverless Error') + res.status(500).json({ error: `Internal Serverless Error - "${err.message}"` }) }) module.exports = app \ No newline at end of file diff --git a/api/config/passport.js b/api/config/passport.js index 000253b..d96ee55 100644 --- a/api/config/passport.js +++ b/api/config/passport.js @@ -1,7 +1,7 @@ /** * Config: Passport.js */ - + const StrategyJWT = require('passport-jwt').Strategy const ExtractJWT = require('passport-jwt').ExtractJwt const { users } = require('../models') @@ -11,16 +11,16 @@ module.exports = (passport) => { const options = {} options.jwtFromRequest = ExtractJWT.fromAuthHeaderAsBearerToken() - options.secretOrKey = process.env.tokenSecret + options.secretOrKey = process.env.tokenSecret || 'secret_j91jasf0j1asfkl' // Change this to only use your own secret token passport.use(new StrategyJWT(options, async (jwtPayload, done) => { let user try { user = await users.getById(jwtPayload.id) } - catch (error) { + catch (error) { console.log(error) - return done(error, null) + return done(error, null) } - console.log(user) + if (!user) { return done(null, false) } return done(null, user) })) diff --git a/api/controllers/users.js b/api/controllers/users.js index 20a3812..4a042df 100644 --- a/api/controllers/users.js +++ b/api/controllers/users.js @@ -16,16 +16,16 @@ const register = async (req, res, next) => { try { await users.register(req.body) - } catch(error) { + } catch (error) { return res.status(400).json({ error: error.message }) } let user - try { - user = await users.getByEmail(req.body.email) - } catch (error) { + try { + user = await users.getByEmail(req.body.email) + } catch (error) { console.log(error) - return next(error, null) + return next(error, null) } const token = jwt.sign(user, process.env.tokenSecret, { diff --git a/api/models/users.js b/api/models/users.js index cfef68f..b29e60f 100644 --- a/api/models/users.js +++ b/api/models/users.js @@ -67,7 +67,7 @@ const getByEmail = async(email) => { throw new Error(`"${email}" is not a valid email address`) } - // Save + // Query const params = { TableName: process.env.db, KeyConditionExpression: 'hk = :hk', @@ -96,7 +96,7 @@ const getById = async(id) => { throw new Error(`"id" is required`) } - // Save + // Query const params = { TableName: process.env.db, IndexName: process.env.dbIndex1, @@ -133,4 +133,4 @@ module.exports = { getByEmail, getById, convertToPublicFormat, -} \ No newline at end of file +} diff --git a/api/serverless.yml b/api/serverless.yml index 6b8b152..99da196 100644 --- a/api/serverless.yml +++ b/api/serverless.yml @@ -1,12 +1,18 @@ -app: fullstack -component: express@1.0.5 -name: fullstack-api +component: express +name: api inputs: + # Express application source code. src: ./ - roleName: ${output:fullstack-permissions.name} - domain: api.${env:domain} + # Permissions required for the AWS Lambda function to interact with other resources + roleName: ${output:permissions.name} + # Enable this when you want to set a custom domain. + # domain: api.${env:domain} + # Environment variables env: - db: ${output:fullstack-db.name} - dbIndex1: ${output:fullstack-db.indexes.gsi1.name} - tokenSecret: ${env:tokenSecret} \ No newline at end of file + # AWS DynamoDB Table name. Needed for the code to access it. + db: ${output:database.name} + # AWS DynamoDB Table Index name. Needed for the code to access it. + dbIndex1: ${output:database.indexes.gsi1.name} + # A secret token to sign the JWT tokens with. + tokenSecret: secret_1234 # Change to secret via environment variable: ${env:tokenSecret} diff --git a/database/serverless.yml b/database/serverless.yml index 538b317..f9081af 100644 --- a/database/serverless.yml +++ b/database/serverless.yml @@ -1,11 +1,12 @@ -app: fullstack -component: aws-dynamodb@1.1.1 -name: fullstack-db +component: aws-dynamodb +name: database inputs: name: ${name}-${stage} region: us-east-1 + # Don't delete the Database Table if "serverless remove" is run deletionPolicy: retain + # Simple, single-table design attributeDefinitions: - AttributeName: hk AttributeType: S @@ -26,4 +27,4 @@ inputs: - AttributeName: sk KeyType: RANGE Projection: - ProjectionType: ALL \ No newline at end of file + ProjectionType: ALL diff --git a/permissions/serverless.yml b/permissions/serverless.yml index 034534b..5229904 100644 --- a/permissions/serverless.yml +++ b/permissions/serverless.yml @@ -1,21 +1,20 @@ -app: fullstack -component: aws-iam-role@2.0.0 -name: fullstack-permissions +component: aws-iam-role +name: permissions inputs: name: ${name}-${stage} - region: us-east-1 - service: lambda.amazonaws.com + region: us-east-1 + service: lambda.amazonaws.com policy: - # Marketing API Logs and Assume Role access + # AWS Lambda function containing Express Logs and Assume Role access - Effect: Allow Action: - sts:AssumeRole - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - Resource: '*' - # Marketing Database access + Resource: "*" + # AWS DynamoDB Table access - Effect: Allow Action: - dynamodb:DescribeTable @@ -24,6 +23,6 @@ inputs: - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem - Resource: - - ${output:fullstack-db.arn} - - ${output:fullstack-db.arn}/index/* \ No newline at end of file + Resource: + - ${output:database.arn} + - ${output:database.arn}/index/* diff --git a/serverless.template.yml b/serverless.template.yml new file mode 100644 index 0000000..6893801 --- /dev/null +++ b/serverless.template.yml @@ -0,0 +1,6 @@ +name: fullstack-app +org: serverlessinc +description: Deploy a serverless fullstack application using Express.js and React on AWS Lambda and AWS HTTP API +keywords: serverless, express, react, fullstack, aws, aws lambda +repo: https://github.com/serverless-components/fullstack-app +license: MIT \ No newline at end of file diff --git a/site/package.json b/site/package.json index 992333f..9525e65 100644 --- a/site/package.json +++ b/site/package.json @@ -6,12 +6,12 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", - "js-cookie": "^2.2.1", - "moment": "^2.24.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.1.2", - "react-scripts": "3.4.1" + "react-scripts": "3.4.3", + "js-cookie": "^2.2.1", + "moment": "^2.24.0", + "react-router-dom": "^5.1.2" }, "scripts": { "start": "react-scripts start", diff --git a/site/serverless.yml b/site/serverless.yml index d6cc09d..845044b 100644 --- a/site/serverless.yml +++ b/site/serverless.yml @@ -1,10 +1,11 @@ -app: fullstack -component: website@1.0.0 -name: fullstack-site +component: website +name: site inputs: + # React application. "hook" runs before deployment to build the source code. "dist" is the built artifact directory which is uploaded. src: src: ./ hook: npm run build dist: build - domain: ${env:domain} \ No newline at end of file + # Enable this when you want to set a custom domain. + # domain: ${env:domain} diff --git a/site/src/config.js b/site/src/config.js new file mode 100644 index 0000000..3044c1b --- /dev/null +++ b/site/src/config.js @@ -0,0 +1,18 @@ +/** + * Global Config + */ + +const config = {} + +// Domains +config.domains = {} + +/** + * API Domain + * Add the domain from your serverless express.js back-end here. + * This will enable your front-end to communicate with your back-end. + * (e.g. 'https://api.mydomain.com' or 'https://091jafsl10.execute-api.us-east-1.amazonaws.com') + */ +config.domains.api = null + +export default config \ No newline at end of file diff --git a/site/src/index.js b/site/src/index.js index 6254ac6..9e3c542 100644 --- a/site/src/index.js +++ b/site/src/index.js @@ -3,19 +3,6 @@ import ReactDOM from 'react-dom' import App from './App' import './index.css' -/** - * Global Config - */ - -window.serverless = {} -window.serverless.urls = {} -if (window.location.host.includes('localhost:')) { - window.serverless.urls.api = `https://api.serverless-fullstack-app-dev.com` -} else { - window.serverless.root = window.location.hostname.replace('www.', '') - window.serverless.urls.api = `https://api.${window.serverless.root}` -} - /** * Render App */ diff --git a/site/src/pages/Auth/Auth.js b/site/src/pages/Auth/Auth.js index d968244..3477728 100644 --- a/site/src/pages/Auth/Auth.js +++ b/site/src/pages/Auth/Auth.js @@ -5,9 +5,9 @@ import { } from 'react-router-dom' import Loading from '../../fragments/Loading' import styles from './Auth.module.css' -import { +import { userRegister, - userLogin, + userLogin, userGet, saveSession, } from '../../utils' @@ -17,7 +17,7 @@ class Auth extends Component { constructor(props) { super(props) - const pathName = window.location.pathname.replace('/','') + const pathName = window.location.pathname.replace('/', '') this.state = {} this.state.state = pathName @@ -37,7 +37,7 @@ class Auth extends Component { */ componentDidMount() { this.setState({ - loading: false + loading: false }) // Clear query params @@ -49,7 +49,7 @@ class Auth extends Component { * Handles a form change */ handleFormTypeChange(type) { - this.setState({ state: type }, + this.setState({ state: type }, () => { this.props.history.push(`/${type}`) }) @@ -78,7 +78,18 @@ class Auth extends Component { // Validate email if (!this.state.formEmail) { - return this.setState({ formError: 'email is required' }) + return this.setState({ + loading: false, + formError: 'email is required' + }) + } + + // Validate password + if (!this.state.formPassword) { + return this.setState({ + loading: false, + formError: 'password is required' + }) } let token @@ -89,12 +100,16 @@ class Auth extends Component { token = await userLogin(this.state.formEmail, this.state.formPassword) } } catch (error) { + console.log(error) if (error.message) { - this.setState({ formError: error.message, loading: false }) + this.setState({ + formError: error.message, + loading: false + }) } else { - this.setState({ - formError: 'Sorry, something unknown went wrong. Please try again.', - loading: false + this.setState({ + formError: 'Sorry, something unknown went wrong. Please try again.', + loading: false }) } return @@ -108,52 +123,44 @@ class Auth extends Component { window.location.replace('/') } - renderLoading() { - return ( -