diff --git a/.gitignore b/.gitignore index 81e3cf6..e21b325 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ cache.dump # Editor-specific /nbproject/* /.idea/* + +.env diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..8832b82 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,8 @@ +const path = require('path'); + +module.exports = { + 'config': path.resolve('.', 'sequelize.js'), + 'models-path': path.resolve('src', 'models'), + 'seeders-path': path.resolve('src', 'seeders'), + 'migrations-path': path.resolve('src', 'migrations') +} \ No newline at end of file diff --git a/README.md b/README.md index 95dd687..f96291a 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,686 @@ _visit www.actionherojs.com for more information_ -This tutorial showcases a realistic Actionhero project. In addition to the `actionhero` project, it uses the [`ah-seqeulzie-plugin`](https://github.com/actionhero/ah-sequelize-plugin) to use models and migrations against a Postgres database. +This tutorial showcases a realistic Bucket List Application with CRUD operations. -## To install: +To simply play around with the application, follow the next steps: -You will need node.js, npm, redis, and postgres available. +1. Make sure you've the following installed on your system + - Redis + - Postgresql + - NodeJS -`npm install` +2. Clone the project -## To Run: +``` +git clone https://github.com/actionhero/actionhero-tutorial.git +&& cd actionhero-tutorial +``` -`npm start` +3. Install the dependencies + +``` +npm install +``` + +4. Configure the database connection in `./src/config/sequelize.js` and run migrations. + +This can be done manually using Sequelize-CLI but for providing ease of use, this is done automatically +by Actionhero when you restart the project. + +``` +npx sequelize-cli db:migrate +``` + +5. Start Dev server and access API's at Localhost +``` +npm run test actions/goal +npm run dev +``` + + +# Tutorial + + +By the end of this tutorial you will become comfortable with the following pieces of Actionhero and build a CRUD based application. + +- Actions +- Web Server Configs +- Routing +- Plugins +- Testing + +We will be building a Bucket List Web App which will cover all the basics to get started with Actionhero. + +
+ +## Prerequisites + +- Make sure you've Nodejs installed. + +- Redis and Postgresql are installed and working locally. + +
+ + +## Project Setup + +### Create a project directory: + +``` +mkdir bucket && cd bucket +``` + +Generate a new Actionhero project + +``` +npx actionhero generate +``` + +This generates a new project with the following directory structure. +We will cover most of them in this tutorial. + +``` + +| +|- src +| - server.ts +| +| - config +| - (project settings) +| +| - actions +| -- (your actions) +| +| - initializers +| -- (any additional initializers you want) +| +| - servers +| -- (custom servers you may make) +| +| - tasks +| -- (your tasks) +| +| - bin +| -- (your custom CLI commands) +| +|- locales +|-- (translation files) +| +|- __tests__ +|-- (tests for your API) +| +| - log +|-- (default location for logs) +| +|- node_modules +|-- (your modules, actionhero should be npm installed in here) +| +|- pids +|-- (pidfiles for your running servers) +| +|- public +|-- (your static assets to be served by /file) +| +readme.md +package.json +``` + +Install the Dependencies + +``` +npm install +``` + +Start the server by running: + + `npm run dev` + + The local server will be running on http://localhost:8080 + + _8080 is the default port but configurable._ + +Check the directory structure and note the following: + +- The preconfigured scripts are available in `./package.json` +- The server port is configured in `src/config/servers/web.ts`. +- In `web.ts` file you may find many server related configurations such as port, headers to send in API calls etc. A few of which we will take up in this tutorial. + +Now make a GET call like +``` +curl http://localhost:8080/api/status +``` + +This will return a JSON similar to below: +``` +{ + "uptime": 345153, + "nodeStatus": "Node Healthy", + "problems": [], + "id": "192.168.29.154", + "actionheroVersion": "22.1.1", + "name": "my_actionhero_project", + "description": "my actionhero project", + "version": "0.1.0", + "consumedMemoryMB": 11.39, + "resqueTotalQueueLength": 0, + "serverInformation": { + "serverName": "my_actionhero_project", + "apiVersion": "0.1.0", + "requestDuration": 4, + "currentTime": 1591682900784 + }, + "requesterInformation": { + "id": "4beecab3c8dabd931c356515c4516b6ed66dcf03-a1a5e979-5f36-49f5-9fac-7e31798f3648", + "fingerprint": "4beecab3c8dabd931c356515c4516b6ed66dcf03", + "messageId": "a1a5e979-5f36-49f5-9fac-7e31798f3648", + "remoteIP": "127.0.0.1", + "receivedParams": { + "action": "status" + } + } +} +``` + +Success! +This verified everything went fine and project is now working. + +_This is a pre-configured status action available via the `/api/status` endpoint_ + + +## Database Setup + +For our Bucket List tutorial we will need a database. We will use Postgresql for the same. +Since Actionhero is quite modular in nature, there are plugins for support. + +We will be using Sequelize which is Node.js ORM for Postgres, MySQL, MariaDB, SQLite and Microsoft SQL server. + +To connect Actionhero with Sequelize we need ah-sequelize-plugin. + +The setup instructions for ah-sequelize-plugin are on it's Github documentation too. + +### Instructions + +1. Install `ah-sequelize-plugin` +``` +npm i ah-sequelize-plugin --save +``` + +2. Add Sequelize Packages +``` +npm install sequelize sequelize-typescript --save +``` +3. Add types and reflexive addons +``` +npm install @types/bluebird @types/validator reflect-metadata --save +``` +4. Add the plugin to `src/config/plugins.ts` file. +Simply replace the empty return statement with: +``` +return { + "ah-sequelize-plugin": { + path: path.join(process.cwd(), "node_modules", "ah-sequelize-plugin") + } + } + +``` +You might also need to import path into the `plugins.ts` file. +`import * as path from "path";` + +5. Add `experimentalDecorators` and `emitDecoratorMetadata` to your Typescript tsconfig.json file: +``` +{ + "compilerOptions": { + "outDir": "./dist", + "allowJs": true, + "module": "commonjs", + "target": "es2018", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["./src/**/*"] +} +``` +6. Add the supported database drive for Postgres. +``` +npm install --save pg pg-hstore +``` + +_This is necessary for using Sequelize._ + +7. One last thing for ease of use we can have is utilize Sequelize CLI. +You can install it by running +``` +npm install --save-dev sequelize-cli +``` + +8. Configure Sequelize by creating `./src/config/sequelize.js`. + +Copy the following configurations in it. +``` +const { URL } = require('url') +const path = require('path') + +const DEFAULT = { + sequelize: config => { + let dialect = "postgres"; + let host = "127.0.0.1"; + let port = "5432"; + let database = "actionhero"; + let username = undefined; + let password = undefined; + + // if your environment provides database information via a single JDBC-style URL like mysql://username:password@hostname:port/default_schema + if (process.env.DATABASE_URL) { + const parsed = new URL(process.env.DATABASE_URL); + if (parsed.username) { + username = parsed.username; + } + if (parsed.password) { + password = parsed.password; + } + if (parsed.hostname) { + host = parsed.hostname; + } + if (parsed.port) { + port = parsed.port; + } + if (parsed.pathname) { + database = parsed.pathname.substring(1); + } + } + + return { + autoMigrate: true, + logging: false, + dialect: dialect, + port: parseInt(port), + database: database, + host: host, + username: username, + password: password, + models: [path.join(__dirname, "..", "models")], + migrations: [path.join(__dirname, "..", "migrations")] + }; + } +}; + +module.exports.DEFAULT = DEFAULT; + +// for the sequelize CLI tool +module.exports.development = DEFAULT.sequelize({ + env: "development", + process: { env: "development" } +}); + +module.exports.staging = DEFAULT.sequelize({ + env: "staging", + process: { env: "staging" } +}); + +module.exports.production = DEFAULT.sequelize({ + env: "production", + process: { env: "production" } +}); +``` + +_Replace the database, username, host, passsword with your Postgresql configuration._ + + +9. To configure `sequelize-cli` use the above configuration and create a `.sequelizerc` file with following config. This file needs to be created in root folder of the project: `./.sequelizerc`. + +``` +const path = require('path'); + +module.exports = { + 'config': path.resolve('.', 'sequelize.js'), + 'models-path': path.resolve('src', 'models'), + 'seeders-path': path.resolve('src', 'seeders'), + 'migrations-path': path.resolve('src', 'migrations') +} +``` + +_The above configuration helps sequelize-cli know about the location to find other configurations and where to create models, seeders, migrations._ + + +10. Last step, to run the `sequelize-cli` from root folder, create a file called `sequelize.js` in the root folder and add the following: + +``` +const sequelizeConfig = require('./src/config/sequelize.js') + +const sequelizeConfigEnv = sequelizeConfig[process.env.NODE_ENV] || sequelizeConfig.DEFAULT +module.exports = sequelizeConfigEnv.sequelize() +``` + +This in turn makes the CLI use configuration from `src/config/sequelize.js`. + +At this point you will need to create database manually in Postgresql before moving on to the migrations. + +## Migrations + +Now that database setup is finished. +Let's get to create Migrations. Migrations help to create the database tables via code and even roll down the changes if needed. + +1. Create a Migration File + +Creating migrations is easy now, since we already have `sequelize-cli` to help. + +``` +npx sequelize-cli migration:generate --name migration-skeleton +``` + +This will create a migration file as `src/migrations/-migration-skeleton.js`. + +2. Add up/down migrations to the newly created migration file: + +``` +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("goals", { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: Sequelize.DataTypes.STRING(50), + allowNull: false, + }, + done: { + type: Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("goals"); + } +}; +``` + +The above asks Sequelize to create a table `goals`. + +To imagine what we just did look at a `goals` table which can be created using above code. + +| id | title | done | +| ---------------| -------------------|------| +| 235b6988-78.. | Watch NodeConf EU | true | +| a37e3990-41.. | Build Rome | false| + + +3. Migrate + +``` +npx sequelize-cli db:migrate +``` + +The above will create a table called `goals` in the configured database. + +### Model + +Since we have the tables in database now created, lets write the code which represents and connects to the corresponding table. + +Create a Model `Goal.ts` in `src/models/` directory with the following code. + +`Goal.ts` + +``` +import { + Table, + Model, + Column, + PrimaryKey, + BeforeCreate, + } from "sequelize-typescript"; + import * as uuid from "uuid"; + + @Table({ tableName: "goals", timestamps: false, paranoid: false }) + export class Goal extends Model { + @PrimaryKey + @Column + id: string; + + @Column + title: string; + + @Column + done: boolean; + + @BeforeCreate + static generateGuid(instance) { + if (!instance.id) { + instance.id = uuid.v4(); + } + } + } + ``` + +In the above Model, Column decorator defines the columns of table ie `id`, `title`, `done`. The generateGuid static function makes sure to generate a guid for the goal before creating a new one. + +### Testing + +Now that we have created our first model, lets start testing the functionality. + +In the `__tests__/models` directory create a file called `goal.ts` and add the following code: + +``` +import { Process } from "actionhero"; +import { Goal } from "../../src/models/Goal"; +const actionhero = new Process(); + +let api; + +describe("Model", () => { + describe("goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + beforeAll(async () => { + Goal.destroy({ truncate: true }); + }); + + test("New Goal gets created", async () => { + const goal = await Goal.create({ + title: "Test Goal", + }); + expect(goal).toHaveProperty("title"); + expect(goal).toHaveProperty("id"); + expect(goal).toHaveProperty("done"); + expect(goal.title).toBe("Test Goal"); + expect(goal.done).toBe(false); + }); + }); +}); + +``` + +This code tests if a new goal gets created in the database with the required properties. +You can further enhance Model testing with various DB checks such as Title cannot be a number etc. + +``` +npm run test models/goal +``` + +Run the above to test the newly created model. + +## Actions + +Actions are the core of the Actionhero framework. These are the basic units of work within every connection type. +We shall now make a connection for our basic CRUD operations. + +Create a file called `goal.ts` in `src/actions` directory. + + + +### GetGoal Action + +This action is for fetching details of a goal with it's particular id. + +``` +export class GetGoal extends Action +``` + +This involves a constructor, which has certain attributes which can be defined for every Action. + + +``` + constructor() { + super(); + this.name = "goal:get"; + this.description = "Get a Goal using GUID as id"; + this.inputs = { + id: { + required: true, + }, + }; + } +``` + +These are generic attributes such as name of action (this is important and we will use it in next section). +Next is a description about the action. +And last one is the inputs which may or may not be added to the request for action. Here since we're requesting the details of a particular goal, it is required. + +``` +this.inputs = { + id: { + required: true, + }, + }; +``` +For other available attributes on inputs checkout the documentation. + +Next part is the action itself. + +``` + async run({ connection, response }) { + const id = connection.params.id; + const goal = await Goal.findOne({ where: { id: id } }); + if (!goal) { + throw new Error(`Goal does not exist with id: ${id}`); + } + response.goal = goal; + } +} +``` + +The fetching of details from database makes it an async action. This involves essentially just 4 lines of code. + +1. Fetching the input param of id +2. Fetching the details from the database using the particular Goal model. +3. Throwing error if the goal does not exist. +4. Assigning the goal as an attribute to the `response` object of action itself. + +_Similarly there are *ListGoals*, *CreateGoal*, *UpdateGoal* and *DeleteGoal* present in the same `actions/goal.ts` files in the demo project_ + +## Routing + +The Actions are now complete but to access them we need to define particular routes for each action. +This is done via the `src/config/routes.ts` file. + +Since `GetGoal` should ideally be a GET action, it can be added in the `routes.ts` file as below: + +``` +get: [ + { path: "/goals/:id", action: "goal:get" }, +], +``` + +The resulting path to run would be +``` +http://localhost:8080/api/goals/ +``` +and the action refers to the Action name which we had given in GetGoal at + +`this.name = "goal:get"`. + +
+ +Similar routes are added in the `routes.ts` file in demo application. + + +The reason the path is prefixed with `/api` is because of a configuration in the web server with the configuration at `src/config/servers/web.ts`: +``` +urlPathForActions: "api" +``` + +## Testing + +To finally test the whole application let's create a basic test at `__tests__/goal.ts` + +First lets set up the Get Goal Test. It has 2 simple parts involving starting of api before everything and stopping it after every test has been executed. +``` +describe("Action", () => { + describe("Get Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); +... +``` + +Let's create a test for basic fail condition if the goal does not exist: + +``` + test("Should throw an error when goal with id does not exist", + async () => { + const id = "5ab0c467-eb53-4288-9390-51f4d5f1f211"; + const response = await specHelper.runAction("goal:get", { + id: id, + }); + expect(response.error).toBeTruthy(); + expect(response.error).toBe( + `Error: Goal does not exist with id: ${id}` + ); + }); +``` + +And a test to check that the correct goal is fetched: + +``` +test("Should response with goal object when goal with passed GUID exists", async () => { + const newGoalTitle = "Cure Cancer"; + const { id } = await specHelper.runAction("goal:create", { + title: newGoalTitle, + }); + expect(id).toBeTruthy(); + const response = await specHelper.runAction("goal:get", { + id: id, + }); + expect(response.goal).toBeTruthy(); + expect(response.error).toBeFalsy(); + expect(response.goal.id).toBe(id); + }); + }); +``` + +To test the actions in `goal.ts` +``` +npm run test actions/goal +``` + +``` +Test Suites: 1 passed, 1 total +Tests: 2 passed, 2 total +``` + +Success all tests are passing! + +
+ +## Conclusion + +We've completed a basic `Bucket Lists` Application and the code is available in the demo project. -## To Test: -`npm test` diff --git a/__tests__/actions/goal.ts b/__tests__/actions/goal.ts new file mode 100644 index 0000000..9f8e881 --- /dev/null +++ b/__tests__/actions/goal.ts @@ -0,0 +1,261 @@ +import { Process, specHelper } from "actionhero"; +const actionhero = new Process(); +let api; + +describe("Action", () => { + describe("Get Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + test("Should throw an error if id is not passed to get goal", async () => { + const response = await specHelper.runAction("goal:get"); + expect(response.error).toBeTruthy(); + expect(response.error).toBe( + "Error: id is a required parameter for this action" + ); + }); + + test("Should throw an error when goal with id does not exist", async () => { + const id = "5ab0c467-eb53-4288-9390-51f4d5f1f211"; + const response = await specHelper.runAction("goal:get", { + id: id, + }); + expect(response.error).toBeTruthy(); + expect(response.error).toBe(`Error: Goal does not exist with id: ${id}`); + }); + + test("Should give a response with goal object when goal with passed GUID exists", async () => { + const newGoalTitle = "Cure Cancer"; + const { id } = await specHelper.runAction("goal:create", { + title: newGoalTitle, + }); + expect(id).toBeTruthy(); + const response = await specHelper.runAction("goal:get", { + id: id, + }); + expect(response.goal).toBeTruthy(); + expect(response.error).toBeFalsy(); + expect(response.goal.id).toBe(id); + expect(response.goal.title).toBe(newGoalTitle); + expect(response.goal.done).toBe(false); + }); + }); + + describe("Create a Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + test("should not create a goal without title param", async () => { + const response = await specHelper.runAction("goal:create"); + expect(response.id).toBeFalsy(); + expect(response.error).toBeTruthy(); + expect(response.error).toBe( + "Error: title is a required parameter for this action" + ); + }); + + test("should not create a goal when the title is not of type string", async () => { + const response = await specHelper.runAction("goal:create", { title: 1 }); + expect(response.id).toBeFalsy(); + expect(response.error).toBeTruthy(); + expect(response.error).toBe( + "Error: Expected type of title to be string got number" + ); + }); + + test("should add a new goal to goals", async () => { + const response1 = await specHelper.runAction("goal:list"); + const oldGoalsCount = response1.goals.length; + await specHelper.runAction("goal:create", { + title: "Visit Jurassic Park.", + }); + const response2 = await specHelper.runAction("goal:list"); + const newGoalsCount = response2.goals.length; + expect(newGoalsCount).toBe(oldGoalsCount + 1); + }); + + test("should return a guid as id for new goal created", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Time Travel", + }); + const isGUID = id.match( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ); + expect(isGUID).toBeTruthy(); + }); + + test("should have 'done' set to false by default for the new goal", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Visit North Pole", + }); + const { goal } = await specHelper.runAction("goal:get", { + id: id, + }); + expect(goal.done).toBe(false); + }); + + test("should create new goal with 'done' set to false even if 'done' is sent as true in the params", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Meet Mickey Mouse", + done: true, + }); + const { goal } = await specHelper.runAction("goal:get", { + id: id, + }); + expect(goal.done).toBe(false); + }); + }); + + describe("Goals List", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + test("should return a goal response object", async () => { + const { goals } = await specHelper.runAction("goal:list"); + expect(goals).toBeDefined(); + }); + + test("should return a goals object which is of type array", async () => { + const { goals } = await specHelper.runAction("goal:list"); + expect(Array.isArray(goals)).toBe(true); + }); + + test("should return goals list of length > 0", async () => { + await specHelper.runAction("goal:create", { title: "Sing on Stage" }); + const { goals } = await specHelper.runAction("goal:list"); + expect(goals.length).toBeGreaterThan(0); + }); + }); + + describe("Update Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + test("Should update title and done status", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Fight Tyson", + }); + const newTitle = "Fight Muhammad Ali"; + const response = await specHelper.runAction("goal:update", { + title: newTitle, + done: true, + id: id, + }); + expect(response.goal).toBeDefined(); + expect(response.goal.title).toBe(newTitle); + expect(response.goal.done).toBe(true); + }); + + test("Should update a single parameter", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Deploy Actionhero API", + }); + const newDoneStatus = false; + const response = await specHelper.runAction("goal:update", { + done: newDoneStatus, + id: id, + }); + expect(response.goal).toBeDefined(); + expect(response.goal.done).toBe(newDoneStatus); + + const newTitle = "Watch NodeConf EU"; + const response2 = await specHelper.runAction("goal:update", { + title: newTitle, + id: id, + }); + expect(response2.goal).toBeDefined(); + expect(response2.goal.title).toBe(newTitle); + }); + + test("Should not update goal", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Defeat Thanos", + }); + const response = await specHelper.runAction("goal:update", { + id: id, + }); + expect(response.error).toBe("Error: No update needed"); + }); + + test("Should throw Error when title or done type is incorrect", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Build Rome", + }); + + const newTitle = 1; + const response = await specHelper.runAction("goal:update", { + title: newTitle, + id: id, + }); + expect(response.goal).toBeUndefined(); + expect(response.error).toBe( + "Error: Expected type of title to be string, got number" + ); + + const newDoneStatus = "Bad Status"; + const response2 = await specHelper.runAction("goal:update", { + done: newDoneStatus, + id: id, + }); + expect(response2.goal).toBeUndefined(); + expect(response2.error).toBe( + "Error: Expected type of done to be boolean, got string" + ); + }); + }); + + describe("Delete Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + test("Should throw error if goal with id does not exist", async () => { + const id = "2d901d96-2244-421d-9f91-38f25f8d565d"; + const { error } = await specHelper.runAction("goal:delete", { + id: id, + }); + expect(error).toBeDefined(); + expect(error).toBe(`Error: No such goal exists with id ${id}`); + }); + + test("Should remove from database the particular goal", async () => { + const { id } = await specHelper.runAction("goal:create", { + title: "Deploy Actionhero API", + }); + const { message } = await specHelper.runAction("goal:delete", { + id: id, + }); + expect(message).toBe("Goal Deleted Successfully"); + + const response = await specHelper.runAction("goal:get", { + id: id, + }); + expect(response.error).toBeTruthy(); + expect(response.error).toBe(`Error: Goal does not exist with id: ${id}`); + }); + }); +}); diff --git a/__tests__/actions/users.ts b/__tests__/actions/users.ts deleted file mode 100644 index d5dfdf8..0000000 --- a/__tests__/actions/users.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Process, specHelper } from "actionhero"; -import { User } from "../../src/models/User"; -const actionhero = new Process(); -let api; - -describe("Action", () => { - describe("users", () => { - beforeAll(async () => { - api = await actionhero.start(); - }); - - afterAll(async () => { - await actionhero.stop(); - }); - - beforeAll(async () => { - User.destroy({ truncate: true }); - }); - - test("a user cannot be created without all the required params", async () => { - const { error } = await specHelper.runAction("user:create"); - expect(error).toMatch(/is a required parameter for this action/); - }); - - test("a user can be created with all the required params", async () => { - const { error, guid } = await specHelper.runAction("user:create", { - firstName: "Mario", - lastName: "Mario", - email: "mario@example.com", - password: "p@ssowrd", - }); - expect(error).toBeFalsy(); - expect(guid).toBeTruthy(); - }); - - test("a user can sign in with the proper password", async () => { - const { error, success } = await specHelper.runAction("user:signIn", { - email: "mario@example.com", - password: "p@ssowrd", - }); - expect(error).toBeFalsy(); - expect(success).toBe(true); - }); - - test("a user cannot sign in with the wrong password", async () => { - const { error, success } = await specHelper.runAction("user:signIn", { - email: "mario@example.com", - password: "bad password", - }); - expect(error).toMatch(/password do not match/); - expect(success).toBe(false); - }); - }); -}); diff --git a/__tests__/models/goal.ts b/__tests__/models/goal.ts new file mode 100644 index 0000000..e729428 --- /dev/null +++ b/__tests__/models/goal.ts @@ -0,0 +1,32 @@ +import { Process } from "actionhero"; +import { Goal } from "../../src/models/Goal"; +const actionhero = new Process(); + +let api; + +describe("Model", () => { + describe("Goal", () => { + beforeAll(async () => { + api = await actionhero.start(); + }); + + afterAll(async () => { + await actionhero.stop(); + }); + + beforeAll(async () => { + Goal.destroy({ truncate: true }); + }); + + test("New Goal gets created", async () => { + const goal = await Goal.create({ + title: "Test Goal Creation", + }); + expect(goal).toHaveProperty("title"); + expect(goal).toHaveProperty("id"); + expect(goal).toHaveProperty("done"); + expect(goal.title).toBe("Test Goal Creation"); + expect(goal.done).toBe(false); + }); + }); +}); diff --git a/__tests__/models/user.ts b/__tests__/models/user.ts deleted file mode 100644 index da271e6..0000000 --- a/__tests__/models/user.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Process, specHelper } from "actionhero"; -import { User } from "../../src/models/User"; -const actionhero = new Process(); -let api; - -describe("Model", () => { - describe("users", () => { - beforeAll(async () => { - api = await actionhero.start(); - }); - - afterAll(async () => { - await actionhero.stop(); - }); - - beforeAll(async () => { - User.destroy({ truncate: true }); - }); - - test("users are unique by email address", async () => { - const user = await User.create({ - firstName: "Mario", - lastName: "Mario", - email: "mario@example.com", - }); - - await expect( - User.create({ - firstName: "Mario Again", - lastName: "Mario Again", - email: "mario@example.com", - }) - ).rejects.toThrow(/Validation error/); - - await user.destroy(); - }); - - test("user passwords can be validate", async () => { - const user = await User.create({ - firstName: "Mario", - lastName: "Mario", - email: "mario@example.com", - }); - - await user.updatePassword("p@ssw0rd"); - let response = await user.checkPassword("p@ssw0rd"); - expect(response).toBe(true); - - response = await user.checkPassword("wrong password"); - expect(response).toBe(false); - - await user.destroy(); - }); - }); -}); diff --git a/package-lock.json b/package-lock.json index 06a93c5..b4d9ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5392,6 +5392,14 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.2.3.tgz", "integrity": "sha512-I/KCSQGmOrZx6sMHXkOs2MjddrYcqpza3Dtsy0AjIgBr/bZiPJRK9WhABXN1Uy1UDazRbi9gZEzO2sAhL5EqiQ==" }, + "pg-hstore": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.3.tgz", + "integrity": "sha512-qpeTpdkguFgfdoidtfeTho1Q1zPVPbtMHgs8eQ+Aan05iLmIs3Z3oo5DOZRclPGoQ4i68I1kCtQSJSa7i0ZVYg==", + "requires": { + "underscore": "^1.7.0" + } + }, "pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -6993,6 +7001,11 @@ "bluebird": "^3.7.2" } }, + "underscore": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz", + "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==" + }, "union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", diff --git a/package.json b/package.json index 3c67641..db81c4c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "author": "YOU ", - "name": "my_actionhero_project", - "description": "my actionhero project", + "author": "Yatin ", + "name": "bucket-list", + "description": "Tutorial project for Bucket List", "version": "0.1.0", "engines": { "node": ">=14.0.0" @@ -15,6 +15,7 @@ "ioredis": "latest", "mysql2": "^2.1.0", "pg": "^8.2.1", + "pg-hstore": "^2.3.3", "reflect-metadata": "^0.1.13", "sequelize": "^5.21.12", "sequelize-typescript": "^1.1.0", @@ -32,7 +33,7 @@ }, "scripts": { "postinstall": "npm run build", - "dev": "ts-node-dev --no-deps --transpile-only ./src/server", + "dev": "export $(cat .env) && ts-node-dev --no-deps --transpile-only ./src/server", "debug": "tsc && ts-node-dev --transpile-only --no-deps --inspect -- ./src/server ", "start": "node ./dist/server.js", "actionhero": "actionhero", diff --git a/sequelize.js b/sequelize.js new file mode 100644 index 0000000..82cfcc1 --- /dev/null +++ b/sequelize.js @@ -0,0 +1,4 @@ +const sequelizeConfig = require('./src/config/sequelize.js') + +const sequelizeConfigEnv = sequelizeConfig[process.env.NODE_ENV] || sequelizeConfig.DEFAULT +module.exports = sequelizeConfigEnv.sequelize(); \ No newline at end of file diff --git a/src/actions/goal.ts b/src/actions/goal.ts new file mode 100644 index 0000000..e357ceb --- /dev/null +++ b/src/actions/goal.ts @@ -0,0 +1,137 @@ +import { Action } from "actionhero"; +import { Goal } from "../models/Goal"; + +export class GetGoal extends Action { + constructor() { + super(); + this.name = "goal:get"; + this.description = "Get a Goal using GUID as id"; + this.inputs = { + id: { + required: true, + }, + }; + } + + async run({ connection, response }) { + const id = connection.params.id; + const goal = await Goal.findOne({ where: { id: id } }); + if (!goal) { + throw new Error(`Goal does not exist with id: ${id}`); + } + response.goal = goal; + } +} + +export class ListGoals extends Action { + constructor() { + super(); + this.name = "goal:list"; + this.description = "List the goals"; + this.outputExample = {}; + } + + async run({ params, response }) { + const goals = await Goal.findAll(); + response.goals = goals; + } +} + +export class CreateGoal extends Action { + constructor() { + super(); + this.name = "goal:create"; + this.description = "Create a goal"; + this.inputs = { + title: { + required: true, + validator: (param, connection, actionTemplate) => { + if (typeof param !== "string") { + throw new Error( + `Expected type of title to be string got ${typeof param}` + ); + } + }, + }, + }; + } + + async run({ params, response }) { + const goal = await Goal.create(params); + response.id = goal.id; + } +} + +export class UpdateGoal extends Action { + constructor() { + super(); + this.name = "goal:update"; + this.description = "Update a goal"; + this.inputs = { + title: { + required: false, + validator: (param) => { + if (typeof param !== "string") { + throw new Error( + `Expected type of title to be string, got ${typeof param}` + ); + } + }, + }, + done: { + required: false, + validator: (param) => { + if (typeof param !== "boolean") { + throw new Error( + `Expected type of done to be boolean, got ${typeof param}` + ); + } + }, + }, + }; + } + + async run({ connection, params, response }) { + // Why is connection required, then why not use connection.params everywhere? + const newTitle = params.title; + const newDoneStatus = params.done; + if (!newTitle && newDoneStatus === undefined) { + throw new Error("No update needed"); + } + const [numberOfUpdatedRows, goals] = await Goal.update( + { title: newTitle, done: newDoneStatus }, + { + where: { + id: connection.params.id, + }, + returning: true, + } + ); + if (numberOfUpdatedRows < 1) { + throw new Error(`No such goal exists`); + } + response.goal = goals[0]; + } +} + +export class DeleteGoal extends Action { + constructor() { + super(); + this.name = "goal:delete"; + this.description = "Delete a Goal"; + } + + async run({ connection, response }) { + const deleteRowCount = await Goal.destroy({ + where: { + id: connection.params.id, + }, + }); + + if (deleteRowCount < 1) { + throw new Error(`No such goal exists with id ${connection.params.id}`); + } + + response.message = "Goal Deleted Successfully"; + } +} diff --git a/src/actions/users.ts b/src/actions/users.ts deleted file mode 100644 index 24fe07f..0000000 --- a/src/actions/users.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Action } from "actionhero"; -import { User } from "../models/User"; - -export class UserCreate extends Action { - constructor() { - super(); - this.name = "user:create"; - this.description = "create a user"; - this.outputExample = {}; - this.inputs = { - firstName: { required: true }, - lastName: { required: true }, - email: { required: true }, - password: { required: true }, - }; - } - - async run({ params, response }) { - const user = await User.create(params); - await user.updatePassword(params.password); - response.guid = user.guid; - } -} - -export class UserSignIn extends Action { - constructor() { - super(); - this.name = "user:signIn"; - this.description = "sign in as a user"; - this.outputExample = {}; - this.inputs = { - email: { required: true }, - password: { required: true }, - }; - } - - async run({ params, response }) { - response.success = false; - - const user = await User.findOne({ where: { email: params.email } }); - if (!user) { - throw new Error("user not found"); - } - - const passwordMatch = await user.checkPassword(params.password); - if (!passwordMatch) { - throw new Error("password do not match"); - } - response.success = true; - } -} diff --git a/src/config/routes.ts b/src/config/routes.ts index 4347372..82eda9b 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -1,30 +1,13 @@ export const DEFAULT = { routes: (config) => { return { - /* --------------------- - routes.js - - For web clients (http and https) you can define an optional RESTful mapping to help route requests to actions. - If the client doesn't specify and action in a param, and the base route isn't a named action, the action will attempt to be discerned from this routes.js file. - - Learn more here: http://www.actionherojs.com/docs/#routes - - examples: - get: [ - { path: '/users', action: 'usersList' }, // (GET) /api/users - { path: '/search/:term/limit/:limit/offset/:offset', action: 'search' }, // (GET) /api/search/car/limit/10/offset/100 + { path: "/goals", action: "goal:list" }, + { path: "/goals/:id", action: "goal:get" }, ], - - post: [ - { path: '/login/:userID(^\\d{3}$)', action: 'login' } // (POST) /api/login/123 - ], - - all: [ - { path: '/user/:userID', action: 'user', matchTrailingPathParts: true } // (*) /api/user/123, api/user/123/stuff - ] - - ---------------------- */ + post: [{ path: "/goals", action: "goal:create" }], + patch: [{ path: "/goals/:id", action: "goal:update" }], + delete: [{ path: "/goals/:id", action: "goal:delete" }], }; }, }; diff --git a/src/config/sequelize.js b/src/config/sequelize.js new file mode 100644 index 0000000..5ba7889 --- /dev/null +++ b/src/config/sequelize.js @@ -0,0 +1,64 @@ +const { URL } = require("url"); +const path = require("path"); + +const DEFAULT = { + sequelize: (config) => { + let dialect = "postgres"; + let host = "127.0.0.1"; + let port = "5432"; + let database = ""; + let username = undefined; + let password = undefined; + + // if your environment provides database information via a single JDBC-style URL like mysql://username:password@hostname:port/default_schema + if (process.env.DATABASE_URL) { + const parsed = new URL(process.env.DATABASE_URL); + if (parsed.username) { + username = parsed.username; + } + if (parsed.password) { + password = parsed.password; + } + if (parsed.hostname) { + host = parsed.hostname; + } + if (parsed.port) { + port = parsed.port; + } + if (parsed.pathname) { + database = parsed.pathname.substring(1); + } + } + + return { + autoMigrate: true, + logging: true, + dialect: dialect, + port: parseInt(port), + database: database, + host: host, + username: username, + password: password, + models: [path.join(__dirname, "..", "models")], + migrations: [path.join(__dirname, "..", "migrations")], + }; + }, +}; + +module.exports.DEFAULT = DEFAULT; + +// for the sequelize CLI tool +module.exports.development = DEFAULT.sequelize({ + env: "development", + process: { env: "development" }, +}); + +module.exports.staging = DEFAULT.sequelize({ + env: "staging", + process: { env: "staging" }, +}); + +module.exports.production = DEFAULT.sequelize({ + env: "production", + process: { env: "production" }, +}); diff --git a/src/config/sequelize.ts b/src/config/sequelize.ts index 676759d..fd434e0 100644 --- a/src/config/sequelize.ts +++ b/src/config/sequelize.ts @@ -6,7 +6,7 @@ const DEFAULT = { let dialect = "postgres"; let host = "127.0.0.1"; let port = "5432"; - let database = "actionhero"; + let database = ""; let username = undefined; let password = undefined; diff --git a/src/config/servers/websocket.ts b/src/config/servers/websocket.ts index 8003a18..18f5cdb 100644 --- a/src/config/servers/websocket.ts +++ b/src/config/servers/websocket.ts @@ -4,7 +4,7 @@ export const DEFAULT = { servers: { websocket: (config) => { return { - enabled: false, + enabled: true, // you can pass a FQDN (string) here or 'window.location.origin' clientUrl: "window.location.origin", // Directory to render client-side JS. diff --git a/src/migrations/01-createUsers.js b/src/migrations/01-createUsers.js deleted file mode 100644 index d89b960..0000000 --- a/src/migrations/01-createUsers.js +++ /dev/null @@ -1,55 +0,0 @@ -module.exports = { - up: async function (migration, DataTypes) { - await migration.createTable( - "users", - { - guid: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - - firstName: { - type: DataTypes.STRING(191), - allowNull: false, - }, - - lastName: { - type: DataTypes.STRING(191), - allowNull: false, - }, - - email: { - type: DataTypes.STRING(191), - allowNull: false, - }, - - passwordHash: { - type: DataTypes.TEXT, - allowNull: true, - }, - - lastLoginAt: { - type: DataTypes.DATE, - allowNull: true, - }, - - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - deletedAt: DataTypes.DATE, - }, - { - charset: "utf8mb4", - } - ); - - await migration.addIndex("users", ["email"], { - unique: true, - fields: "email", - }); - }, - - down: async function (migration) { - await migration.dropTable("users"); - }, -}; diff --git a/src/migrations/20200613014915-create-goals.js b/src/migrations/20200613014915-create-goals.js new file mode 100644 index 0000000..50c5faf --- /dev/null +++ b/src/migrations/20200613014915-create-goals.js @@ -0,0 +1,38 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("goals", { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: Sequelize.DataTypes.STRING(50), + allowNull: false, + }, + done: { + type: Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + deletedAt: { + allowNull: true, + type: Sequelize.DATE, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("goals"); + }, +}; diff --git a/src/models/Goal.ts b/src/models/Goal.ts new file mode 100644 index 0000000..bf05467 --- /dev/null +++ b/src/models/Goal.ts @@ -0,0 +1,28 @@ +import { + Table, + Model, + Column, + PrimaryKey, + BeforeCreate, +} from "sequelize-typescript"; +import * as uuid from "uuid"; + +@Table({ tableName: "goals", timestamps: true }) +export class Goal extends Model { + @PrimaryKey + @Column + id: string; + + @Column + title: string; + + @Column + done: boolean; + + @BeforeCreate + static generateId(instance) { + if (!instance.id) { + instance.id = uuid.v4(); + } + } +} diff --git a/src/models/User.ts b/src/models/User.ts deleted file mode 100644 index 89e3c08..0000000 --- a/src/models/User.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as bcrypt from "bcrypt"; -import { - Model, - Table, - Column, - AllowNull, - IsEmail, - BeforeCreate, -} from "sequelize-typescript"; -import * as uuid from "uuid"; - -@Table({ tableName: "users", paranoid: false }) -export class User extends Model { - saltRounds = 10; - - @Column({ primaryKey: true }) - guid: string; - - @AllowNull(false) - @Column - firstName: string; - - @AllowNull(false) - @Column - lastName: string; - - @AllowNull(false) - @IsEmail - @Column - email: string; - - @Column - passwordHash: string; - - @BeforeCreate - static generateGuid(instance) { - if (!instance.guid) { - instance.guid = uuid.v4(); - } - } - - async updatePassword(password: string) { - this.passwordHash = await bcrypt.hash(password, this.saltRounds); - await this.save(); - } - - async checkPassword(password: string) { - if (!this.passwordHash) { - throw new Error("password not set for this team member"); - } - - const match = await bcrypt.compare(password, this.passwordHash); - return match; - } -}