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;
- }
-}