浏览代码

Initial commit (after many hours work :eyes:)

main
Roxie Gibson 4 年前
当前提交
283c197754
找不到此签名对应的密钥
共有 11 个文件被更改,包括 1752 次插入0 次删除
  1. +3
    -0
      .gitignore
  2. +162
    -0
      README.md
  3. +1040
    -0
      package-lock.json
  4. +24
    -0
      package.json
  5. +16
    -0
      products.json
  6. +265
    -0
      src/api.ts
  7. +40
    -0
      src/db/connection.ts
  8. +120
    -0
      src/db/models.ts
  9. +4
    -0
      src/index.ts
  10. +66
    -0
      tsconfig.json
  11. +12
    -0
      tslint.json

+ 3
- 0
.gitignore 查看文件

@@ -0,0 +1,3 @@
node_modules/
*.js
dist/

+ 162
- 0
README.md 查看文件

@@ -0,0 +1,162 @@
# Product REST API

A REST API designed for a backend code test for Mission Labs. The background in this fake world is a Mission Labs merch store with a product database for the web store. API has all 4 CRUD methods for its products. Authentication protects any write methods with the X-Token header. All methods below are documents and the code base has comments and documentation throughout.

## Setup

Requires node.js 14+, npm, and mongodb

### Installation

```sh
npm install
```

### MongoDb setup

Make sure you have properly setup a MongoDB instance and its running on the default port. Then run these two commands to insert our test data and set a field as an index.

```sh
mongoimport -d ml-test -c products --jsonArray products.json
mongo localhost/ml-test --eval "db.products.createIndex( { identifier: 1 }, { unique: true } )"
```

## Usage

To run the server:

```sh
npm run prod
```

You should see this output

```sh
> ml-crud_api@1.0.0 prod /home/roxie/Projects/ts/mission-labs/code-test
> tsc && node ./dist/index.js

API running @ http://127.0.0.1:8081
```

Great! You are now running the REST API server. It is accessible at http://127.0.0.1:8081. For this demo, the secret key token for authentication is `SECRET_KEY`.

## API Docs

The product schema will be as follows...

```json
{
"identifier": "url-safe-identifier", // REQUIRED - CAN NOT be empty, needs to be url-safe and lowercase
"name": "Example Product", // REQUIRED - CAN NOT be empty
"price": 9000, // REQUIRED - Price is in pence, CAN NOT be negative
"category": "Examples", // REQUIRED - CAN NOT be empty
"sizes": ["Example Sized"] // OPTIONAL - Can be empty
}
```

Searching is done with identifiers instead of ObjectID ID's to make API more human-readable and accessible.

Items in curly braces are parameters.

### List all products

`GET /api/products`

This endpoint accepts two parameters to filter the output:

- `priceFrom`: Define a minimum price of all products in the search
- `priceTo`: Define a maximum price of all products in the search

All prices are listed as pence for ease of use.

#### Responses

- `200 OK` on success

```json
[
{
"identifier": "url-safe-identifier",
"name": "Example Product",
"price": 9000, // Price is in pence
"category": "Examples",
"sizes": ["Example Sized"]
}
]
```

### Get one product

`GET /api/products/{IDENTIFIER}`

#### Responses

- `200 OK` on success.
- `404 Not Found` if product with identifier doesn't exist.

```json
{
"identifier": "url-safe-identifier",
"name": "Example Product",
"price": 9000, // Price is in pence
"category": "Examples",
"sizes": ["Example Sized"]
}
```

### Create new product

`POST /api/products/`

#### Responses

- `200 OK` on success/
- `400 Bad Request` when trying to create an object with the same identifier as an existing object.
- `401 Unauthorized` on request without header `X-Token` being set to an authorized key.

Posted data should be a raw body with the header `Content-Type` set to `application/json`. Example of the posted JSON would look like this

```json
{
"identifier": "ml-cool-logo-cup",
"name": "Brand New Mugs",
"price": 500, // Price is in pence
"category": "Liquid Holders"
}
```

### Update product

`PATCH /api/products/{IDENTIFIER}`

Edits part of a product object (or all) with the data received.

#### Responses

- `200 OK` on success.
- `400 Bad Request` if unable to parse some or all of the data received for updating.
- `401 Unauthorized` on request without header `X-Token` being set to an authorized key.
- `404 Not Found` if product with identifier doesn't exist.

Posted data should be a raw body with the header `Content-Type` set to `application/json`. `identifier` must be an identifier to an already existing product or else response will be `404 Not Found`. Partial updates are allowed when using PATCH. Example of the posted JSON would look like this

```json
// /api/products/mug_1

{
"name": "Mugs One!",

}
```

This would change the name of the item.

### Delete product

`DELETE /api/products/{IDENTIFIER}`

#### Responses

- `204 No Content` on delete success.
- `401 Unauthorized` on request without header `X-Token` being set to an authorized key.
- `404 Not Found` if product with identifier doesn't exist.

+ 1040
- 0
package-lock.json
文件差异内容过多而无法显示
查看文件


+ 24
- 0
package.json 查看文件

@@ -0,0 +1,24 @@
{
"name": "ml-crud_api",
"version": "1.0.0",
"description": "Code test for Mission Labs",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node ./src/index.ts",
"prod": "tsc && node ./dist/index.js"
},
"author": "Roxie Gibson",
"license": "ISC",
"devDependencies": {
"ts-node": "^8.10.1",
"tslint": "^6.1.2"
},
"dependencies": {
"@typegoose/typegoose": "^7.1.3",
"@types/express": "^4.17.6",
"@types/mongoose": "^5.7.21",
"express": "^4.17.1",
"mongoose": "^5.9.16",
"typescript": "^3.9.3"
}
}

+ 16
- 0
products.json 查看文件

@@ -0,0 +1,16 @@
[
{
"identifier": "ml_t-shirt",
"name": "Mission Labs T-Shirt",
"price": 2000,
"sizes": ["XS", "S", "M", "L", "XL"],
"category": "Merch"
},
{
"identifier": "ml_beanie",
"name": "Mission Labs Beanie",
"price": 999,
"sizes": ["OSFA"],
"category": "Merch"
}
]

+ 265
- 0
src/api.ts 查看文件

@@ -0,0 +1,265 @@

import express from "express";
import { Application, NextFunction, Request, Response, json as jsonBodyParser } from "express";
import { Product, ProductModel } from "./db/models";
import { QueryOperators, connect as connectDB } from "./db/connection";
import { post } from "@typegoose/typegoose";



/**
* Middleware for the server to have a simple authentication system.
* Checks headers to see if "X-Token" is set to "SECRET_KEY". If it isn't, then the server will return a 401 due to lack of authentication.
* @param req - request object
* @param res - response object
* @param next - next function in the chain
*/
function checkAuthorization(req: Request, res: Response, next: NextFunction) {
const token: string | undefined = req.get("X-Token");
if (token === "SECRET_KEY") {
next();
} else {
res.status(401).end(); // Return 401 Unauthorized due to no authentication
}
}

/**
* Converts the integer parameters in the API requests
* @param param - the string parameter passed by the user
* @throws Error if the string cannot be converted into a positive integer and thus can't be used to filter the database query.
*/
function parseParamInt(param: string): number {
const int = parseInt(param, 10);
if ( isNaN(int) || int < 0 ) {
throw new Error("Param cannot be processed into a valid number");
}
return int;
}


/**
* Gets all products in the database
*
* This endpoint accepts two parameters to filter the output:
* - `priceFrom`: Define a minimum price of all products in the search
* - `priceTo`: Define a maximum price of all products in the search
*
* ~~Responses~~
* `200 OK` on success
* @param req - request object
* @param res - response object
*/
async function getProductsEndpoint(req: Request, res: Response): Promise<void> {
const priceFromStr = req.query.priceFrom;
const priceToStr = req.query.priceTo;
let priceFrom: number = -1;
let priceTo: number = -1;
try {
if (typeof priceFromStr === "string") {
priceFrom = parseParamInt(priceFromStr);
}
if (typeof priceToStr === "string") {
priceTo = parseParamInt(priceToStr);
}
} catch(error) {
// Error processing numbers
return res.status(400).end("One or more parameters could not be processed.");
}

// If these variables are below 0, then they are still the original number set before parsing
const priceFromValid = priceFrom >= 0;
const priceToValid = priceTo >= 0;

if (priceFromValid && priceToValid) {
if (priceFrom > priceTo) {
return res.status(400).end("priceFrom parameter is bigger than priceTo parameter.");
}
}

let options = {};
if (priceFromValid || priceToValid) {
// If at least one parameter is parseable
const queryOperators: QueryOperators = {};
if (priceFromValid) {
queryOperators.$gte = priceFrom;
}
if (priceToValid) {
queryOperators.$lte = priceTo;
}
options = {price: queryOperators};
}

const products = await ProductModel.find(options) as Product[];
// Filter product objects in query to remove non-useful data
const filteredProducts = products.map(product => { return Product.isValid(product); });
res.json(filteredProducts);
}

/**
* Gets one product in the database using the unique identifier
*
* ~~Responses~~
* `200 OK` on success
* `404 Not Found` if product with identifier doesn't exist.
* @param req - request object
* @param res - response object
*/
async function getProductEndpoint(req: Request, res: Response): Promise<void> {
const product = await ProductModel.findOne({identifier: req.params.identifier});
if (!product) {
return res.status(404).end(`Product with identifier ${req.params.identifier} could not be found.`);
}
// Run DB output through validator to strip any database specific keys (ObjectID's and version ID's)
const validProduct: Product = Product.isValid(product);
res.json(validProduct);
}

/**
* Creates a product. req.body is processed from middleware ran before this function. Parsed from raw data to a json object.
*
* ~~Responses~~
* `200 OK` on success
* `400 Bad Request` when trying to create an object with the same identifier as an existing object.
* `401 Unauthorized` on request without header `X-Token` being set to an authorized key. See checkAuthorization middleware function.
* @param req - request object
* @param res - response object
*/
async function createProductEndpoint(req: Request, res: Response): Promise<void> {
let postData: Product;
try {
postData = Product.isValid(req.body);
} catch (error) {
// Thrown error if data received is not valid and cannot be inserted into DB
return res.status(400).end("Request has invalid data. Cannot be added to database.");
}

const identifierAlreadyExists = await ProductModel.findOne({
identifier: postData.identifier
});
if (identifierAlreadyExists) {
return res.status(400).end("Supplied identifier already exists in database of products.");
}
try {
ProductModel.create(postData);
return res.status(200).end("Successfully added product to database.");
} catch (error) {
// Generic error handler for unexpected issue
return res.status(500).end("Something unexpected happened and the server was unable to process your request.");
}
}

// TODO: COMMENT
async function editProductEndpoint(req: Request, res: Response): Promise<void> {
const appendObject = (object1: object, object2: object) => {
return {...object1, ...object2};
};

const postData = req.body;
let updateData = {};
let failedValidation: boolean = false;

const keys: Record<string, any> = {
"identifier": Product.stringIsUrlSafe,
"name": Product.stringIsValid,
"price": Product.numberIsValid,
"category": Product.stringIsValid,
"sizes": Product.arrayIsValid
};

Object.keys(postData).forEach((key) => {
if (keys.hasOwnProperty(key)) {
// Execute validation function tied to the key in the above object
const value = postData[key];
const validationFunction = (keys)[key];
if (validationFunction(value)) {
const newData: Record<string, any> = {};
newData[key] = value;
updateData = appendObject(updateData, newData);
} else {
// One of the fields could not be validated
failedValidation = true;
}
}
});

if (failedValidation){
return res.status(400).end("Could not update product as non-valid data was provided.");
}

if (updateData) {
const updatedProduct = await ProductModel.updateOne({identifier: req.params.identifier}, updateData);
if (updatedProduct) {
res.status(200).end(`${req.params.identifier} was successfully updated.`);
} else {
// No product was found in the database, nothing was updated
res.status(404).end(`${req.params.identifier} could not be found.`);
}

} else {
return res.status(400).end("Could not update product as no valid fields were given.");
}
}

/**
* Deletes the product with the identifier given.
*
* ~~Responses~~
* `204 No Content` on delete success.
* `401 Unauthorized` on request without header `X-Token` being set to an authorized key. See checkAuthorization middleware function.
* `404 Not Found` if product with identifier doesn't exist.
* @param req - request object
* @param res - response object
*/
async function deleteProductEndpoint(req: Request, res: Response): Promise<void> {
const product = await ProductModel.findOneAndDelete({identifier: req.params.identifier});
if (!product) {
// If something was found and deleted, it returns the deleted object
// If no return, then the object wasn't in the database!
return res.status(404).end(`Product with identifier ${req.params.identifier} could not be found.`);
} else {
return res.status(204).end(`${req.params.identifier} has been deleted.`);
}
}

/**
* Defines all endpoints with middleware
* @param server - express Application to register endpoints on.
*/
function defineEndpoints(server: Application): void {
// REGISTER API ROUTES
const apiBaseEndpoint = "/api/products";
const specifiedProductEndpoint = `${apiBaseEndpoint}/:identifier`;
// Middleware to parse json in raw body requests
const jsonParser = jsonBodyParser();

// GET all products
server.get(apiBaseEndpoint, getProductsEndpoint);

// GET specified product
server.get(specifiedProductEndpoint, getProductEndpoint);

// POST create new product
// middleware - Check if client is authorized, Parse JSON from body, then try and create product
server.post(apiBaseEndpoint, [checkAuthorization, jsonParser, createProductEndpoint]);

// PATCH edit specified product
// middleware - Check if client is authorized, Parse JSON from body, then try and edit product
server.patch(specifiedProductEndpoint, [checkAuthorization, jsonParser, editProductEndpoint]);

// DELETE specified product
// middleware - Check if client is authorized, then delete product
server.delete(specifiedProductEndpoint, [checkAuthorization, deleteProductEndpoint]);
}

/**
* Runs REST API server. Makes sure the connection to the mongodb is established.
*/
export function runServer(): void {
const server = express();
connectDB();
defineEndpoints(server);
// Run Server
server.listen(8081, () => {
console.log("API running @ http://127.0.0.1:8081");
});
}

+ 40
- 0
src/db/connection.ts 查看文件

@@ -0,0 +1,40 @@

import "mongoose";
import { mongoose } from "@typegoose/typegoose";

/**
* Connects to the MongoDB database running on the default port.
*/
export function connect(): void {
const url = "mongodb://localhost:27017/";
const db = mongoose.connection;

// Exit on error since program relies on db connection
db.on('error', (err) => {
// One weird issue with this is the connection takes a while to timeout
console.error(err);
process.exit(2);
});

mongoose.connect(url, {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true,
dbName: "ml-test"
});
}

/**
* Defines the query operators for querying the mongodb. This is to make this a lot easier to pass the object to filter a value.
* More here about it https://docs.mongodb.com/manual/reference/operator/query-logical/
*/
export interface QueryOperators {
$eq?: any;
$gt?: any;
$gte?: any;
$in?: any[];
$lt?: any;
$lte?: any;
$ne?: any;
$nin?: any[];
}

+ 120
- 0
src/db/models.ts 查看文件

@@ -0,0 +1,120 @@

import { arrayProp, prop, getModelForClass } from '@typegoose/typegoose';

/**
* @class A interface for the Product schema. The model is derived from the properties and their decorators.
* The class also has a lot of static methods to validate itself and its properties.
*/
export class Product {
@prop({ required: true, unique: true, dropDupes: true })
public identifier!: string;
@prop({ required: true })
public name!: string;
@prop({ required: true })
public price!: number;
@arrayProp({ items: String })
public sizes?: string[];
@prop({ required: true })
public category!: string;

/**
* Validates sizes array, making sure it either is undefined, or defined. All items have to be strings.
* @param possibleArray - Object to run tests against. Can be anything.
* @returns true or false to whether the object is a valid array
*/
public static arrayIsValid(possibleArray: any): boolean {
if (Array.isArray(possibleArray)) {
const allItemsStrings = possibleArray.every( (value) => { return typeof value === "string"; });
if (!allItemsStrings) {
return false;
} else {
return true;
}
} else if (typeof possibleArray === "undefined") {
// No array provided is valid
return true;
} else {
// sizes is not array but also not undefined
return false;
}
}

/**
* Validates strings. Checks if they are a string and that they are not empty.
* @param possibleArray - Object to run tests against. Can be anything.
* @returns true or false to whether the object is a valid string
*/
public static stringIsValid(possibleString: any): boolean {
if (typeof possibleString === "string") {
// Aka if not empty
if (possibleString.replace(" ", "")) {
return true;
}
}
return false;
}

/**
* Takes a string and checks if it only contains alphanumeric characters
* @param str - string to check if url safe. Could be anything, hence the check during the function.
* @returns boolean of if string is alphanumeric. If not a string, returns false.
*/
public static stringIsUrlSafe(str: any): boolean {
if (this.stringIsValid(str)) {
const newString = str.replace(/[^a-z0-9\-\_]/gi, '_').toLowerCase();
if (newString === str) { return true; }
}
return false;
}

/**
* Takes a string and checks if it only contains alphanumeric characters
* @param str - string to check if url safe. Could be anything, hence the check during the function.
* @returns boolean of if string is alphanumeric. If not a string, returns false.
*/
public static numberIsValid(int: any): boolean {
if (typeof int === "number") {
if (int >= 0) { return true; }
}
// For simplicity, we will reject strings that can be converted to numbers for now
return false;
}

/**
* Validates object if it can be cast to product interface.
* Checks if all required fields are provided and that the data meets the guidelines.
* If extra fields given, we will just ignore them. Possible issue here because we are failing quietly.
* @param jsonInput - Object to be casted to Product interface
* @returns Product interface with valid fields and data
*/
public static isValid(jsonInput: Product): Product {
// Check if keys exist and types are correct
const isValid =
this.stringIsUrlSafe(jsonInput.identifier) &&
this.stringIsValid(jsonInput.name) &&
this.numberIsValid(jsonInput.price) &&
this.stringIsValid(jsonInput.category);

const arrayIsValid = this.arrayIsValid(jsonInput.sizes);

if (!isValid || !arrayIsValid) {
throw new Error("POST data is missing required keys or data provided is not valid to the schema.");
}

// Build object to return
const validatedProduct: Product = {
identifier: jsonInput.identifier,
name: jsonInput.name,
price: jsonInput.price,
category: jsonInput.category,
};

if (arrayIsValid) {
validatedProduct.sizes = jsonInput.sizes;
}

return validatedProduct;
}
}

export const ProductModel = getModelForClass(Product);

+ 4
- 0
src/index.ts 查看文件

@@ -0,0 +1,4 @@

import { runServer } from "./api";
// TODO: Add logging to endpoints and general running for sysadmin
runServer();

+ 66
- 0
tsconfig.json 查看文件

@@ -0,0 +1,66 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */

/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */

/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */

/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */

/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

+ 12
- 0
tslint.json 查看文件

@@ -0,0 +1,12 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {
"no-console": false,
"semicolon": true
},
"rulesDirectory": []
}

正在加载...
取消
保存