├── .gitignore ├── yaml-server.png ├── .coveragebadgesrc ├── src ├── config.js ├── __tests__ │ ├── db.yml │ ├── original-db.yml │ ├── db.json │ ├── original-db.json │ └── server.js ├── fileHelper.js ├── index.js └── server.js ├── CONTRIBUTING.md ├── db.yml ├── db.json ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── azure-pipelines.yml ├── .vscode └── settings.json ├── .eslintrc.json ├── badges └── coverage.svg ├── CHANGELOG.md ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage -------------------------------------------------------------------------------- /yaml-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softchris/yaml-server/HEAD/yaml-server.png -------------------------------------------------------------------------------- /.coveragebadgesrc: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./coverage/coverage-summary.json", 3 | "attribute": "total.statements.pct" 4 | } -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = (dbName) => ({ dbPath: path.join(process.cwd(), dbName) }); 4 | 5 | 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This project welcomes contributions and suggestions. 2 | 3 | Any feature suggestion, bugs, tests. Please raise an issue at `https://github.com/softchris/gatsby-cli/issues`. -------------------------------------------------------------------------------- /db.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - id: 1 3 | name: tomato 4 | - id: 2 5 | name: lettuce 6 | orders: 7 | - id: 1 8 | name: order1 9 | - id: 2 10 | name: order2 11 | -------------------------------------------------------------------------------- /src/__tests__/db.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - id: 1 3 | name: tomato 4 | - id: 2 5 | name: lettuce 6 | orders: 7 | - id: 1 8 | name: order1 9 | - id: 2 10 | name: order2 -------------------------------------------------------------------------------- /src/__tests__/original-db.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - id: 1 3 | name: tomato 4 | - id: 2 5 | name: lettuce 6 | orders: 7 | - id: 1 8 | name: order1 9 | - id: 2 10 | name: order2 -------------------------------------------------------------------------------- /src/__tests__/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [{ 3 | "id": 1, 4 | "name": "tomato" 5 | }, { 6 | "id": 2, 7 | "name": "lettuce" 8 | }], 9 | "orders": [{ 10 | "id": 1, 11 | "name": "order1" 12 | }, 13 | { 14 | "id": 1, 15 | "name": "order1" 16 | }] 17 | } -------------------------------------------------------------------------------- /src/__tests__/original-db.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [{ 3 | "id": 1, 4 | "name": "tomato" 5 | }, { 6 | "id": 2, 7 | "name": "lettuce" 8 | }], 9 | "orders": [{ 10 | "id": 1, 11 | "name": "order1" 12 | }, 13 | { 14 | "id": 1, 15 | "name": "order1" 16 | }] 17 | } -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [ 3 | { 4 | "id": 1, 5 | "name": "tomato" 6 | }, 7 | { 8 | "id": 2, 9 | "name": "lettuce" 10 | } 11 | ], 12 | "orders": [ 13 | { 14 | "id": 1, 15 | "name": "order1" 16 | }, 17 | { 18 | "id": 2, 19 | "name": "order2" 20 | }, 21 | { 22 | "id": 3, 23 | "name": "order4" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /src/fileHelper.js: -------------------------------------------------------------------------------- 1 | const YAML = require('yaml'); 2 | 3 | module.exports = { 4 | toJson(content, dialect) { 5 | if (dialect === 'yaml') { 6 | const doc = YAML.parseDocument(content); 7 | json = doc.toJSON(); 8 | return json; 9 | } 10 | return JSON.parse(content); // just return JSON 11 | }, 12 | transform(content, dialect) { 13 | if (dialect === 'yaml') { 14 | return YAML.stringify(content); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Node.js 2 | # Build a general Node.js project with npm. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: 'ubuntu-latest' 11 | 12 | steps: 13 | - task: NodeTool@0 14 | inputs: 15 | versionSpec: '10.x' 16 | displayName: 'Install Node.js' 17 | 18 | - script: | 19 | npm install 20 | displayName: 'npm install and build' 21 | 22 | - script: | 23 | npm run test 24 | displayName: 'npm test' 25 | 26 | - task: PublishCodeCoverageResults@1 27 | inputs: 28 | codeCoverageTool: Cobertura # or JaCoCo 29 | summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/*coverage.xml' 30 | reportDirectory: '$(System.DefaultWorkingDirectory)/**/coverage' 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#cc3636", 4 | "activityBar.activeBorder": "#134c13", 5 | "activityBar.background": "#cc3636", 6 | "activityBar.foreground": "#e7e7e7", 7 | "activityBar.inactiveForeground": "#e7e7e799", 8 | "activityBarBadge.background": "#134c13", 9 | "activityBarBadge.foreground": "#e7e7e7", 10 | "statusBar.background": "#a52a2a", 11 | "statusBar.border": "#a52a2a", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#cc3636", 14 | "titleBar.activeBackground": "#a52a2a", 15 | "titleBar.activeForeground": "#e7e7e7", 16 | "titleBar.border": "#a52a2a", 17 | "titleBar.inactiveBackground": "#a52a2a99", 18 | "titleBar.inactiveForeground": "#e7e7e799" 19 | }, 20 | "peacock.color": "brown" 21 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "jest/globals": true 7 | }, 8 | "extends": [ "eslint:recommended", "plugin:node/recommended" ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 11 15 | }, 16 | "plugins": ["jest"], 17 | "rules": { 18 | "semi": [ 19 | "error", 20 | "always" 21 | ], 22 | "quotes": [ 23 | "error", 24 | "double" 25 | ], 26 | "jest/no-disabled-tests": "warn", 27 | "jest/no-focused-tests": "error", 28 | "jest/no-identical-title": "error", 29 | "jest/prefer-to-have-length": "warn", 30 | "jest/valid-expect": "error", 31 | "node/no-unpublished-require": "off", 32 | "node/no-extraneous-require": "off" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /badges/coverage.svg: -------------------------------------------------------------------------------- 1 | CoverageCoverage100%100% -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 - 2020-05-20 4 | 5 | - Adding support for query parameters `page` and `pageSize` 6 | 7 | ## 1.2.1 - 2020-05-20 8 | 9 | - Made run instructions clearer 10 | 11 | ## 1.3.1 - 2020-05-20 12 | 13 | - Adding support for new resource creation `//new` 14 | 15 | ## 1.3.2 - 2020-05-20 16 | 17 | - Test added to ensure no new resource is created with `//new` if resource already exist 18 | 19 | ## 1.3.3 - 2020-05-21 20 | 21 | - Adding CORS, all requests are CORS enabled 22 | 23 | ## 1.4.4 - 2020-05-21 24 | 25 | - Adding ESLint 26 | 27 | ## 1.5.0 - 2020-05-21 28 | 29 | - Adding CI with Azure Devops 30 | 31 | ## 1.6.0 - 2020-05-21 32 | 33 | - Added ability to sort. 34 | 35 | ## 1.7.0 - 2020-05-21 36 | 37 | - Static file hosting, use `--static` to specify what directory, otherwise root is used. 38 | 39 | ## 1.8.0 - 2020-05-21 40 | 41 | - Auto start browser at `http://localhost:/info`. 42 | 43 | ## 1.9.0 - 2020-05-29 44 | 45 | - Hot reload, you can now edit the db file and the server will restart by itself. 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const argv = require("yargs").argv; 3 | const fs = require('fs'); 4 | 5 | function getDefaultByDialect(dialect) { 6 | switch (dialect) { 7 | case 'yaml': 8 | return 'db.yml' 9 | case 'json': 10 | return 'db.json' 11 | default: 12 | return ''; 13 | } 14 | } 15 | 16 | const { createServer, getHttpServer } = require("./server"); 17 | const dialect = argv.dialect || 'yaml'; 18 | const { dbPath } = require("./config")(getDefaultByDialect(dialect)); 19 | 20 | const port = argv.port || 3000; 21 | const database = argv.database || dbPath; 22 | const autoStart = argv.autoStart === 'off' ? false : true; 23 | const hotReload = argv.hotReload === "off" ? false : true; 24 | 25 | createServer(port, database, argv.static, autoStart, dialect); 26 | 27 | if (hotReload) { 28 | fs.watchFile(database, (curr, prev) => { 29 | console.log("Database changed"); 30 | getHttpServer().close(() => { 31 | console.log("Restarting the server"); 32 | createServer(port, database, argv.static, autoStart); 33 | }); 34 | }); 35 | } 36 | 37 | 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Chris Noring 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yaml-server", 3 | "version": "1.10.0", 4 | "engines": { 5 | "node": ">=8.3.0" 6 | }, 7 | "bin": { 8 | "yaml-server": "./src/index.js" 9 | }, 10 | "description": "A command line tool that creates a REST server from a YAML file that you specify", 11 | "main": "index.js", 12 | "scripts": { 13 | "start": "node ./src/index.js", 14 | "test": "jest --coverage", 15 | "test:watch": "jest --watchAll", 16 | "premake-badge": "$(npm bin)/jest --coverage", 17 | "make-badge": "$(npm bin)/coverage-badges", 18 | "toc": "npx markdown-toc README.md", 19 | "lint": "npx eslint ./src/**" 20 | }, 21 | "jest": { 22 | "coverageReporters": [ 23 | "text", 24 | "lcov", 25 | "json-summary", 26 | "cobertura" 27 | ] 28 | }, 29 | "keywords": [ 30 | "YAML", 31 | "YML", 32 | "server", 33 | "fake", 34 | "REST", 35 | "API", 36 | "prototyping", 37 | "mock", 38 | "mocking", 39 | "test", 40 | "testing", 41 | "rest", 42 | "data" 43 | ], 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/softchris/yaml-server.git" 47 | }, 48 | "homepage": "https://softchris.github.io/yaml-server.html", 49 | "bugs": { 50 | "url": "https://github.com/softchris/yaml-server/issues" 51 | }, 52 | "author": "Chris Noring (https://softchris.github.io)", 53 | "contributors": [ 54 | { 55 | "name": "chris noring", 56 | "url": "https://softchris.github.io" 57 | } 58 | ], 59 | "license": "MIT", 60 | "dependencies": { 61 | "chalk": "^4.0.0", 62 | "cors": "^2.8.5", 63 | "express": "^4.17.1", 64 | "opn": "^6.0.0", 65 | "yaml": "^1.9.2", 66 | "yargs": "^15.3.1" 67 | }, 68 | "devDependencies": { 69 | "coverage-badges": "^1.0.4", 70 | "eslint": "^7.0.0", 71 | "eslint-plugin-jest": "^23.13.1", 72 | "eslint-plugin-node": "^11.1.0", 73 | "jest": "^26.0.1", 74 | "markdown-toc": "^1.2.0", 75 | "supertest": "^4.0.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at christoffer.noring@microsoft.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://dev.azure.com/devrel/chris-testing/_apis/build/status/softchris.yaml-server?branchName=master)](https://dev.azure.com/devrel/chris-testing/_build/latest?definitionId=23&branchName=master) 2 | ![Coverage](./badges/coverage.svg) 3 | [![npm version](https://badge.fury.io/js/yaml-server.svg)](https://www.npmjs.com/package/yaml-server) 4 | [![npm downloads](https://img.shields.io/npm/dm/yaml-server?color=blue&label=npm%20downloads&style=flat-square)](https://www.npmjs.com/package/yaml-server) 5 | [![The MIT License](https://img.shields.io/badge/license-MIT-orange.svg?color=blue&style=flat-square)](http://opensource.org/licenses/MIT) 6 | 7 | ## Table of Contents 8 | 9 | - [About](#about) 10 | - [Features](#features) 11 | - [Install](#install) 12 | - [Run](#run) 13 | - [Routes](#routes) 14 | 15 | ## About 16 | 17 | Recognition, this project wouldn't be here with out the great `json-server`. I thought to myself that JSON was a little verbose. So I created `yaml-server` so you can have a Mock REST API based on a YAML file instead. 18 | 19 | `yaml-server` is a command line tool that create a REST server based on a YAML file. 20 | 21 | ![Application running](yaml-server.png) 22 | 23 | ## Features 24 | 25 | - **RESTful API** Do HTTP requests towards a Mock API using GET, PUT, POST and DELETE created off of a `db.yml` file. 26 | - **Filter** your GET calls with query parameters `page` and `pageSize`, example: 27 | 28 | ```bash 29 | /products?page=1&pageSize=10 30 | ``` 31 | 32 | - **JSON support**, yes you can have your database in JSON as well. All you need is to specify the `--dialect` argument like so: 33 | 34 | ```bash 35 | npx yaml-server --dialect=json 36 | ``` 37 | 38 | The above will look after a `db.json` file at the root. You override where it looks for this if you specify `--database` like for example: 39 | 40 | ```bash 41 | npx yaml-server --dialect=json --database ./db/db.json 42 | ``` 43 | 44 | Above you need to ensure the `db.json` is located in sub directory `db` as seen from the root. 45 | 46 | - **Create new resource**, make a POST call with the following format `//new`, example: 47 | 48 | ```bash 49 | /kittens/new 50 | ``` 51 | 52 | Ensure you have a payload as well, looking like for example `{ title: 'paw paw' }` 53 | 54 | - **Sorting, by order and key**, you can sort a resource response using query parameters `sortOrder` and `sortKey`. Assume we have the resource `/kittens` where one kitten object looks like so `{ id: 1, name: 'paws' }` and the entire resource looks like so: 55 | 56 | ```javascript 57 | [{ 58 | id: 1, 59 | name: 'paws' 60 | }, { 61 | id: 2, 62 | name: 'alpha paw' 63 | }] 64 | ``` 65 | 66 | Use sorting by appending `sortOrder` and `sortKey` like below: 67 | 68 | ```bash 69 | /kittens?sortOrder=ASC&sortKey=name 70 | ``` 71 | 72 | This would give the response: 73 | 74 | ```javascript 75 | [{ 76 | id: 2, 77 | name: 'alpha paw' 78 | }, { 79 | id: 1, 80 | name: 'paws' 81 | }] 82 | ``` 83 | 84 | - **browser autostart**, the Browser auto starts at `http://locallhost:/info`. Should you not wish to have that behavior, you can shut it off like so: 85 | 86 | ```bash 87 | npx yaml-server --autoStart=off 88 | ``` 89 | 90 | - **Static file server** 91 | 92 | By default a static file server is starting up to host your files at root directory. You can change that by specifying `--static`. Here's how you would do that: 93 | 94 | ```bash 95 | npx yaml-server --static=public 96 | ``` 97 | 98 | The above would start a static file server from the sub folder `public`. 99 | 100 | - **Hot reload** 101 | 102 | The server will restart if you make changes to your database file. No need for closing and starting the server after a database change. Should you not wish that behavior, you can shut it off with: 103 | 104 | ```bash 105 | npx yaml-server --hotReload=off 106 | ``` 107 | 108 | ## Install 109 | 110 | Either install it globally with: 111 | 112 | ```bash 113 | npm install -g yaml-server 114 | ``` 115 | 116 | OR use `NPX` 117 | 118 | ```bash 119 | npx yaml-server --port 3000 --database ./db.yml 120 | ``` 121 | 122 | ## Run 123 | 124 | 1. Create a `db.yml`. 125 | 1. Give `db.yml` an example content, for example: 126 | 127 | ```yaml 128 | products: 129 | - id: 1 130 | name: tomato 131 | - id: 2 132 | name: lettuce 133 | orders: 134 | - id: 1 135 | name: order1 136 | - id: 2 137 | name: order2 138 | ``` 139 | 140 | 1. There are two ways to start: 141 | 1. **Quick start**, run `npx yaml-server`, this will start a server on `http://localhost:3000` and base it off a `db.yml` at the project root that you created. 142 | 1. **With parameters**, You can also configure like so `npx yaml-server --port 8000 --database ./db/mydb.yml` (If you place db file under `./db/mydb.yml`) 143 | 144 | ### See your routes 145 | 146 | Open up a browser and navigate to `http://localhost:/info`. Default port is `3000`, if you specified port use that as port instead. 147 | 148 | The page at route `http://localhost:/info` will tell you what routes and operations are available. Here's a typical response for the default page: 149 | 150 | ```output 151 | Welcome to YAML Server 152 | 153 | Routes available are: 154 | 155 | GET /products 156 | GET /products/:id 157 | PUT /products 158 | DELETE /products/:id 159 | 160 | GET /orders 161 | GET /orders/:id 162 | PUT /orders 163 | DELETE /orders/:id 164 | ``` 165 | 166 | ## Routes 167 | 168 | Routes are created from a YAML file. The default value is `db.yml`. You can name it whatever you want though. 169 | 170 | Routes are first level elements. Consider the following example file: 171 | 172 | ```yml 173 | # db.yml 174 | products: 175 | - id: 1 176 | name: tomato 177 | - id: 2 178 | name: lettuce 179 | orders: 180 | - id: 1 181 | name: order1 182 | - id: 2 183 | name: order2 184 | ``` 185 | 186 | This will produce routes `/products`, `/orders`. Below is a table of supported operations with `products` as example resource. The same operations are also supports for `orders/`. 187 | 188 | | VERB |Route | Input | Output | 189 | |----------|---------------|------------|--------------------| 190 | | GET | /products | *None* | **Array** | 191 | | GET | /products/:id | **e.g 3** | **Object** | 192 | | POST | /products | **object** | **Created object** | 193 | | PUT | /products | **object** | **Updated object** | 194 | | DELETE | /products/:id | **e.g 3** | **Deleted object** | 195 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const YAML = require("yaml"); 2 | const fs = require("fs"); 3 | var bodyParser = require("body-parser"); 4 | const cors = require("cors"); 5 | const chalk = require("chalk"); 6 | const express = require("express"); 7 | const { toJson, transform } = require('./fileHelper'); 8 | const opn = require('opn'); 9 | 10 | let httpServer; 11 | 12 | function createServer(portNumber, dbPath, staticDirectory, autoStart = true, dialect = 'yaml') { 13 | let app = express(); 14 | app.use(cors()); 15 | 16 | const port = portNumber || 3000; 17 | 18 | app.use(bodyParser.json()); 19 | 20 | let routes = []; 21 | let json; 22 | if (!fs.existsSync(dbPath)) { 23 | console.log(chalk.redBright(`DB does not exist at path "${dbPath}"`)); 24 | } else { 25 | const content = fs.readFileSync(dbPath, { encoding: "utf8" }); 26 | json = toJson(content, dialect) 27 | routes = Object.keys(json); 28 | } 29 | 30 | let staticPath = staticDirectory || "."; 31 | app.use(express.static(staticPath)); 32 | console.log(chalk.blueBright(`Static files served from ${staticPath}/`)); 33 | 34 | const routesString = routes.reduce((acc, curr) => { 35 | return `${acc} GET /${curr} \n GET /${curr}/:id \n PUT /${curr} \n DELETE /${curr}/:id \n\n`; 36 | }, ""); 37 | 38 | const routeDefault = (req, res) => res.send(`Welcome to YAML Server \n 39 | Routes available are: \n 40 | ${routesString} 41 | `); 42 | 43 | function setupRoutesForResource(route) { 44 | app.get(`/${route}`, (req, res) => routeGet(req, res, route)); 45 | app.get(`/${route}/:id`, (req, res) => routeGetWithParam(req, res, route)); 46 | app.post("/:newRoute/new", (req, res) => routeNewResource(req, res, route)); 47 | app.post(`/${route}`, (req, res) => routePost(req, res, route)); 48 | app.put(`/${route}`, (req, res) => routePut(req, res, route)); 49 | app.delete(`/${route}/:id`, (req, res) => routeDelete(req, res, route)); 50 | } 51 | 52 | function routeGet(req, res, route) { 53 | const page = req.query.page; 54 | const pageSize = req.query.pageSize; 55 | const sortOrder = req.query.sortOrder; 56 | const sortKey = req.query.sortKey; 57 | const sortOrders = [ 'ASC', 'DESC' ]; 58 | 59 | const sortAscending = (a, b) => { 60 | console.log('sort ascending') 61 | if (a[sortKey] > b[sortKey]) { 62 | return 1; 63 | } else if (a[sortKey] < b[sortKey]) { 64 | return -1; 65 | } 66 | return 0; 67 | } 68 | 69 | const sortDescending = (a, b) => { 70 | if (a[sortKey] > b[sortKey]) { 71 | return -1; 72 | } else if (a[sortKey] < b[sortKey]) { 73 | return 1; 74 | } 75 | return 0; 76 | } 77 | 78 | if (sortKey && json[route][0][sortKey] === undefined) { 79 | res.statusCode = 400; 80 | res.send(`${sortKey} is not a valid sort key`); 81 | } 82 | 83 | if (sortOrder && sortOrders.includes(sortOrder)) { 84 | const sortMethod = sortOrder === 'ASC'? sortAscending : sortDescending; 85 | const copyArr = [ ...json[route]]; 86 | copyArr.sort(sortMethod) 87 | res.json(copyArr) 88 | } else if (!sortOrder && sortKey) { 89 | const copyArr = [...json[route]]; 90 | copyArr.sort(sortAscending) 91 | res.json(copyArr) 92 | } 93 | 94 | if (/\d+/.test(page) && /\d+/.test(pageSize)) { 95 | const pageNo = +page; 96 | const pageSizeNo = +pageSize; 97 | const start = pageSizeNo * (+pageNo - 1); 98 | const end = start + pageSizeNo; 99 | res.json(json[route].slice(start, end)); 100 | } 101 | res.json(json[route]); 102 | } 103 | 104 | function routeGetWithParam(req, res, route) { 105 | const foundItem = json[route].find((item) => item.id == req.params.id); 106 | if (!foundItem) { 107 | res.statusCode = 404; 108 | res.json({}); 109 | } else { 110 | res.json(foundItem); 111 | } 112 | } 113 | 114 | function routeNewResource(req, res) { 115 | const { newRoute } = req.params; 116 | 117 | if (!json[newRoute]) { 118 | json[newRoute] = { ...req.body, id: 1 }; 119 | setupRoutesForResource(newRoute); 120 | fs.writeFileSync(dbPath, transform(json, dialect)); 121 | res.statusCode = 201; 122 | res.json({ ...req.body, id: 1 }); 123 | } else { 124 | res.statusCode = 400; 125 | res.send(`/${newRoute} already exist`); 126 | } 127 | } 128 | 129 | function routePost(req, res, route) { 130 | const posted = { ...req.body, id: 0 }; 131 | 132 | if (json[route].length > 0) { 133 | const [firstItem] = json[route]; 134 | const props = [...Object.keys(firstItem)].sort(); 135 | const postedProps = Object.keys(posted).sort(); 136 | 137 | if (JSON.stringify(props) !== JSON.stringify(postedProps)) { 138 | res.statusCode = 400; 139 | res.send(""); 140 | } 141 | } 142 | 143 | const insertObject = { ...posted, id: json[route].length + 1 }; 144 | 145 | json[route].push(insertObject); 146 | 147 | fs.writeFileSync(dbPath, transform(json, dialect)); 148 | res.statusCode = 201; 149 | res.json(insertObject); 150 | } 151 | 152 | function routePut(req, res, route) { 153 | const posted = req.body; 154 | let foundItem; 155 | json[route] = json[route].map((item) => { 156 | if (item.id === +posted.id) { 157 | foundItem = item; 158 | return { ...item, ...posted }; 159 | } 160 | return item; 161 | }); 162 | if (foundItem) { 163 | fs.writeFileSync(dbPath, transform(json, dialect)); 164 | res.json({ ...foundItem, ...posted }); 165 | } else { 166 | res.statusCode = 404; 167 | res.send("Item not found with ID" + posted.id); 168 | } 169 | } 170 | 171 | function routeDelete(req, res, route) { 172 | let deletedItem; 173 | json[route] = json[route].filter((item) => { 174 | if (item.id === +req.params.id) { 175 | deletedItem = item; 176 | } 177 | return item.id !== +req.params.id; 178 | }); 179 | fs.writeFileSync(dbPath, transform(json, dialect)); 180 | return res.json(deletedItem); 181 | } 182 | 183 | app.get("/info", routeDefault); 184 | 185 | 186 | routes.forEach(setupRoutesForResource); 187 | 188 | httpServer = app.listen(port, async() => { 189 | console.log(`Example app listening on port ${port}!`); 190 | console.log(chalk.greenBright(routesString)); 191 | if (autoStart) { 192 | const ref = await opn(`http://localhost:${port}/info`); 193 | } 194 | }); 195 | return app; 196 | } 197 | 198 | function getHttpServer() { 199 | return httpServer; 200 | } 201 | 202 | module.exports = { 203 | createServer, 204 | getHttpServer 205 | }; 206 | -------------------------------------------------------------------------------- /src/__tests__/server.js: -------------------------------------------------------------------------------- 1 | const { createServer, getHttpServer } = require("../server"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const supertest = require("supertest"); 5 | const jsonOption = { 6 | dialect: 'json', 7 | dbFilename: 'db.json' 8 | }; 9 | 10 | const ymlOption = { 11 | dialect: 'yaml', 12 | dbFilename: 'db.yml' 13 | }; 14 | 15 | function createOption(option) { 16 | if (option === 'yaml') { 17 | return ymlOption 18 | } else if (option === 'json') { 19 | return jsonOption; 20 | } else { 21 | throw new Error(`Unknown option ${option}`) 22 | } 23 | } 24 | 25 | const selectedOption = createOption('json'); 26 | 27 | const server = createServer( 28 | 3000, 29 | path.join(__dirname, selectedOption.dbFilename), 30 | ".", 31 | false, 32 | selectedOption.dialect 33 | ); 34 | 35 | const request = supertest(server); 36 | 37 | function restore(dialect) { 38 | if (dialect === 'yaml') { 39 | const original = fs.readFileSync(path.join(__dirname, "original-db.yml")); 40 | fs.writeFileSync(path.join(__dirname, "db.yml"), original); 41 | } else if (dialect === 'json') { 42 | const original = fs.readFileSync(path.join(__dirname, "original-db.json")); 43 | fs.writeFileSync(path.join(__dirname, "db.json"), original); 44 | } 45 | } 46 | 47 | describe("server", () => { 48 | afterAll(async(done) => { 49 | // restore yml 50 | // TODO, ensure we have an original based on dialect 51 | // const original = fs.readFileSync(path.join(__dirname, "original-db.yml")); 52 | // fs.writeFileSync(path.join(__dirname, "db.yml"), original); 53 | restore(selectedOption.dialect); 54 | getHttpServer().close(() => { 55 | console.log("server closed!"); 56 | done(); 57 | }); 58 | }); 59 | 60 | test("should return products", async(done) => { 61 | const products = [{ 62 | id: 1, 63 | name: "tomato" 64 | }, { 65 | id: 2, 66 | name: "lettuce" 67 | }]; 68 | 69 | const res = await request.get("/products"); 70 | expect(res.status).toBe(200); 71 | expect(res.body).toEqual(products); 72 | done(); 73 | }); 74 | 75 | test("should sort the data based on sortOrder and sortKey", async () => { 76 | const expected = [{ id: 2, name: 'lettuce' }, { id: 1, name: 'tomato' }]; 77 | 78 | const res = await request.get('/products?sortOrder=ASC&sortKey=name') 79 | expect(expected).toEqual(res.body); 80 | }) 81 | 82 | test("should sort the data based on sortOrder and sortKey - DESCENDING", async () => { 83 | const expected = [{ id: 1, name: 'tomato' }, { id: 2, name: 'lettuce' }]; 84 | 85 | const res = await request.get('/products?sortOrder=DESC&sortKey=name') 86 | expect(expected).toEqual(res.body); 87 | }) 88 | 89 | test("should sort with ascending when sortOrder is missing but sortKey is present", async () => { 90 | const expected = [{ id: 2, name: 'lettuce' }, { id: 1, name: 'tomato' }]; 91 | 92 | const res = await request.get('/products?sortKey=name') 93 | expect(expected).toEqual(res.body); 94 | }) 95 | 96 | test("should sort not respect sortOrder when sortOrder value is NOT ASC or DESC", async () => { 97 | const expected = [{ id: 1, name: 'tomato' }, { id: 2, name: 'lettuce' }]; 98 | 99 | const res = await request.get('/products?sortOrder=abc&sortKey=name') 100 | expect(expected).toEqual(res.body); 101 | }) 102 | 103 | test("should respond with 400 when sortKey is not a valid column", async () => { 104 | const expected = [{ id: 1, name: 'tomato' }, { id: 2, name: 'lettuce' }]; 105 | 106 | const res = await request.get('/products?sortOrder=abc&sortKey=notValidKey') 107 | 108 | expect(res.status).toBe(400) 109 | expect(res.text).toBe('notValidKey is not a valid sort key') 110 | }) 111 | 112 | test("should filter by query parameters", async() => { 113 | const firstItem = { id: 1, name: "tomato" }; 114 | const secondItem = { id: 2, name: "lettuce" }; 115 | 116 | let res = await request.get("/products?page=1&pageSize=1"); 117 | let [ item ] = res.body; 118 | expect(res.body).toHaveLength(1); 119 | expect(item).toEqual(firstItem); 120 | 121 | res = await request.get("/products/?page=2&pageSize=1"); 122 | item = res.body[0]; 123 | expect(res.body).toHaveLength(1); 124 | expect(item).toEqual(secondItem); 125 | }); 126 | 127 | test("should NOT respect filter when query param missing", async() => { 128 | const res = await request.get("/products?page=1"); 129 | expect(res.body).toHaveLength(2); 130 | }); 131 | 132 | test("should NOT respect filter when query param has wrong value type", async () => { 133 | const res = await request.get("/products?page=1&pageSize=abc"); 134 | expect(res.body).toHaveLength(2); 135 | }); 136 | 137 | test("should return intro text on default route", async() => { 138 | const res = await request.get("/info"); 139 | expect(res.text).toMatch(/Welcome to YAML Server/); 140 | }); 141 | 142 | test("should return a product", async(done) => { 143 | const product = { id: 1, name: "tomato" }; 144 | const res = await request.get("/products/1"); 145 | expect(res.status).toBe(200); 146 | expect(res.body).toEqual(product); 147 | done(); 148 | }); 149 | 150 | test("should return 404 resource not found", async (done) => { 151 | const res = await request.get("/products/3"); 152 | expect(res.status).toBe(404); 153 | done(); 154 | }); 155 | 156 | test("should add product to /products", async(done) => { 157 | const createdRecord = { id: 3, name: "cucumber" }; 158 | 159 | const res = await request 160 | .post("/products") 161 | .send({ name : "cucumber" }); 162 | expect(res.status).toBe(201); 163 | expect(res.body).toEqual(createdRecord); 164 | done(); 165 | }); 166 | 167 | test("should update product", async(done) => { 168 | const changeTo = { id : 3, name: "gurkin" }; 169 | let res = await request 170 | .put("/products") 171 | .send({ id: 3, name: "gurkin" }); 172 | expect(res.status).toBe(200); 173 | expect(res.body).toEqual(changeTo); 174 | 175 | res = await request.get("/products/3"); 176 | expect(changeTo).toEqual(res.body); 177 | done(); 178 | }); 179 | 180 | test("should return 404 and error message when trying to update non existing item", async() => { 181 | const nonExistingItem = { id: 99, name: "unknown" }; 182 | let res = await request 183 | .put("/products") 184 | .send(nonExistingItem); 185 | expect(res.status).toBe(404); 186 | expect(res.text).toBe("Item not found with ID" + nonExistingItem.id); 187 | }); 188 | 189 | test("should delete product", async(done) => { 190 | const deletedItem = { id: 3, name: "gurkin" }; 191 | let res = await request.delete("/products/3"); 192 | expect(res.status).toBe(200); 193 | expect(res.body).toEqual(deletedItem); 194 | 195 | res = await request.get("/products/3"); 196 | expect(res.status).toBe(404); 197 | done(); 198 | }); 199 | 200 | test("should respect existing schema on POST", async() => { 201 | let res = await request 202 | .post("/products") 203 | .send({ title: "should not work" }); 204 | 205 | expect(res.status).toBe(400); 206 | }); 207 | 208 | test("should create a new resource based on a //new/ call", async() => { 209 | const kitten = { id: 1, title: "paw paw" }; 210 | let res = await request 211 | .post("/kittens/new") 212 | .send({ title: "paw paw" }); 213 | 214 | expect(res.status).toBe(201); 215 | expect(res.body).toEqual(kitten); 216 | 217 | res = await request.get("/kittens"); 218 | expect(res.status).toBe(200); 219 | expect(res.body).toEqual(kitten); 220 | }); 221 | 222 | test("should NOT create a new resource //new/ call when resource already exist", async() => { 223 | let res = await request 224 | .post("/kittens/new") 225 | .send({ title: "paw paw" }); 226 | 227 | expect(res.status).toBe(400); 228 | expect(res.text).toBe("/kittens already exist"); 229 | }); 230 | }); --------------------------------------------------------------------------------