├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── dist ├── config │ ├── index.d.ts │ ├── index.js │ ├── types.d.ts │ └── types.js ├── create-restful-resource.d.ts ├── create-restful-resource.js ├── index.d.ts ├── index.js └── utils │ ├── create-model-route.d.ts │ ├── create-model-route.js │ ├── create-nested-model-prefix.d.ts │ └── create-nested-model-prefix.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── config │ ├── index.ts │ └── types.ts ├── create-model-resource.test.ts ├── create-restful-resource.ts ├── index.ts └── utils │ ├── create-model-route.test.ts │ ├── create-model-route.ts │ ├── create-nested-model-prefix.test.ts │ └── create-nested-model-prefix.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /assets/vendor/** 2 | /assets 3 | 4 | # Created by .ignore support plugin (hsz.mobi) 5 | ### Node template 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # Nuxt generate 75 | dist 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless 82 | 83 | # IDE 84 | .idea 85 | 86 | /**/*.test.js 87 | test/**/* 88 | server/**/* 89 | scripts/**/* 90 | vue-shim.d.ts 91 | .nuxt/**/* 92 | modules/fontawesome/templates/plugin.js 93 | modules/stripe/templates/plugin.js 94 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const restrictedGlobals = require('eslint-restricted-globals') 2 | .filter(name => name !== 'self'); 3 | 4 | module.exports = { 5 | parserOptions: { 6 | parser: '@typescript-eslint/parser', 7 | sourceType: 'module', 8 | ecmaVersion: 2018, 9 | }, 10 | extends: [ 11 | 'airbnb-base', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'prettier/@typescript-eslint', 14 | 'plugin:prettier/recommended', 15 | ], 16 | globals: { 17 | chrome: true, 18 | it: true, 19 | xit: true, 20 | describe: true, 21 | beforeAll: true, 22 | beforeEach: true, 23 | afterEach: true, 24 | afterAll: true, 25 | before: true, 26 | after: true, 27 | chai: true, 28 | sinon: true, 29 | expect: true, 30 | jest: true, 31 | test: true, 32 | cy: true, 33 | Cypress: true, 34 | }, 35 | env: { 36 | browser: true, 37 | node: true, 38 | }, 39 | rules: { 40 | 'arrow-parens': ['error', 'as-needed'], 41 | 'class-methods-use-this': 0, 42 | 'comma-dangle': ['error', 'always-multiline'], 43 | 'curly': ['error', 'all'], 44 | 'consistent-return': 0, 45 | 'dot-notation': [2, { allowPattern: '^[a-z]+(_[a-z]+)+$' }], 46 | 'func-names':2, 47 | 'function-paren-newline': 0, 48 | 'func-style': [ 'error', 'declaration', { 'allowArrowFunctions': false } ], 49 | 'global-require': 0, 50 | 'import/extensions': 0, 51 | 'import/export': 0, 52 | 'import/named': 2, 53 | 'import/no-default-export': 0, 54 | 'import/no-extraneous-dependencies': 0, 55 | 'import/no-unresolved': 0, 56 | 'import/prefer-default-export': 0, 57 | 'import/order': 2, 58 | 'implicit-arrow-linebreak': 0, 59 | 'linebreak-style': process.env.NODE_ENV !== 'production' ? 'off' : ['error', 'unix'], 60 | 'max-len': ['error', { 61 | code: 150, 62 | ignoreUrls: true, 63 | ignoreStrings: true, 64 | ignoreComments: true, 65 | ignoreTemplateLiterals: true, 66 | ignoreRegExpLiterals: true, 67 | ignorePattern: '

||' 68 | }], 69 | 'max-params': ['error', 3], 70 | 'no-console': 2, 71 | 'no-debugger': 1, 72 | 'no-nested-ternary': 0, 73 | 'no-plusplus': 0, 74 | 'no-mixed-operators': 0, 75 | 'no-param-reassign': ['error', { props: false }], 76 | 'no-use-before-define': 0, 77 | 'no-restricted-syntax': ['error', 'WithStatement'], 78 | 'no-return-assign': 0, 79 | 'no-restricted-globals': ['error'].concat(restrictedGlobals), 80 | 'operator-linebreak': [2, 'after', { 'overrides': { '?': 'before', ':': 'before' } }], 81 | 'object-curly-newline': 0, 82 | 'operator-linebreak': 0, 83 | 'prefer-destructuring': 0, 84 | 'require-jsdoc': 0, 85 | 'space-before-function-paren': ['error', 'never'], 86 | 'spaced-comment': 0, 87 | 'sort-keys': 0, 88 | '@typescript-eslint/camelcase': 0, 89 | '@typescript-eslint/indent': 0, 90 | '@typescript-eslint/array-type': ['error', 'array'], 91 | '@typescript-eslint/interface-name': [true, 'never'], 92 | '@typescript-eslint/no-angle-bracket-type-assertion': 0, 93 | '@typescript-eslint/no-explicit-any': 0, 94 | '@typescript-eslint/no-namespace': 0, 95 | '@typescript-eslint/no-object-literal-type-assertion': 0, 96 | '@typescript-eslint/no-unused-vars': ['error'], 97 | '@typescript-eslint/no-use-before-define': 0, 98 | '@typescript-eslint/explicit-function-return-type': 0, 99 | 'valid-jsdoc': 0, 100 | }, 101 | }; 102 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - name: Cache node modules 18 | uses: actions/cache@v1 19 | with: 20 | path: node_modules 21 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.OS }}-build-${{ env.cache-name }}- 24 | ${{ runner.OS }}-build- 25 | ${{ runner.OS }}- 26 | 27 | - name: Install Dependencies 28 | run: npm install 29 | 30 | - name: Release 31 | run: npm run release 32 | 33 | - name: Test 34 | run: npm run unit 35 | 36 | - name: Lint 37 | run: npm run lint 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | --- 4 | 5 | # RESTful Resource 6 | 7 | > The JavaScript URL builder for creating consistent and RESTful resource requests, so you don't have to. 8 | 9 | 10 | 11 | --- 12 | 13 | ### Installation 14 | 15 | ```ts 16 | npm i @hikerfeed/restful-resource --save 17 | ``` 18 | 19 | --- 20 | 21 | ### Usage 22 | 23 | RESTful Resource is a JavaScript URL builder for creating consistent and RESTful resource requests, so you don't have to. RESTful resource does _not_ make HTTP requests. Instead, it generates the proper routes that would match the controller name. For example, take this Laravel route which maps to a controller action: 24 | 25 | 26 | ```php 27 | Route::get('/users', 'UsersController@index'); 28 | ``` 29 | 30 | You can utilize RESTful resource like: 31 | 32 | ```ts 33 | import { createRestfulResource } from '@hikerfeed/restful-resource'; 34 | import http from 'my-http'; 35 | 36 | const UserResource = createRestfulResource('users'); 37 | 38 | http.get(UserResource.index()); // => '/users' 39 | ``` 40 | 41 | Calling `UserResource.index()` will return the appropriate route for REST conventions, in this case for the `index` action. Each resource method accepts a `String`, `Number`, or `Array`. You may call any of the standard REST methods as such: 42 | 43 | ```ts 44 | const UserResource = createRestfulResource('users'); 45 | 46 | http.get(UserResource.index()); // => '/users' 47 | http.post(UserResource.create()); // => '/users/create' 48 | http.post(UserResource.store()); // => '/users' 49 | http.get(UserResource.show(3)); // => '/users/3' 50 | http.get(UserResource.edit(4)); // => '/users/4/edit' 51 | http.patch(UserResource.update(5)); // => '/users/5' 52 | http.delete(UserResource.destroy('5')); // => '/users/5' 53 | ``` 54 | 55 | This works nicely with a framework like Laravel which allows you to define a controller as a resource: 56 | 57 | ```php 58 | Route::resource('users', 'UsersController'); 59 | ``` 60 | 61 | --- 62 | 63 | #### Nested Routes 64 | 65 | In plenty of cases you may have nested routes such as `/users/2/photos`. In this case, you can simply add a `.` between names. If you want to pass an id to the parent and child resource you may pass an `Array` of numbers. 66 | 67 | ```ts 68 | const UserPhotosResource = createRestfulResource('users.photos'); 69 | 70 | http.get(UserPhotosResource.index(2)); // => '/users/2/photos' 71 | http.get(UserPhotosResource.update([2, 33])); // => '/users/2/photos/33' 72 | ``` 73 | 74 | --- 75 | 76 | #### Excluding Routes 77 | 78 | Let's say you have a controller on your backend that excludes the actions such as `create`, `edit`. In Laravel, it may look like this: 79 | 80 | ```php 81 | Route::resource('hikes', 'HikesController')->except(['create', 'edit']); 82 | ``` 83 | 84 | This would generate the following routes: 85 | 86 | - `GET /hikes HikesController@index` 87 | - `POST /hikes HikesController@store` 88 | - `GET /hikes HikesController@show` 89 | - `POST /hikes HikesController@update` 90 | - `POST /hikes HikesController@destroy` 91 | 92 | 93 | To ensure you're not calling routes that don't exist on your API, you can pass the `except` option like so: 94 | 95 | ```ts 96 | // typescript 97 | import { createRestfulResource, RestfulResource } from '@hikerfeed/restful-resource'; 98 | 99 | const HikesResource = createRestfulResource('hikes', { 100 | except: [RestfulResource.Routes.Create, RestfulResource.Routes.Edit], 101 | }); 102 | 103 | HikesResource.index(); // /hikes 104 | HikesResource.create() // throws an Error 105 | ``` 106 | 107 | ```js 108 | // javascript 109 | import { createRestfulResource } from '@hikerfeed/restful-resource'; 110 | 111 | const HikesResource = createRestfulResource('hikes', { 112 | except: ['create', 'edit'], 113 | }); 114 | 115 | HikesResource.index(); // /hikes 116 | HikesResource.create() // throws an Error 117 | ``` 118 | 119 | --- 120 | 121 | #### Only Including Certain Routes 122 | 123 | On the contrary, you may want to _only_ include certain routes. In Laravel this may look like: 124 | 125 | ```php 126 | Route::resource('hikes', 'HikesController')->only(['index']); 127 | ``` 128 | 129 | You may pass an `only` option like so: 130 | 131 | ```ts 132 | // typescript 133 | import { createRestfulResource, RestfulResource } from '@hikerfeed/restful-resource'; 134 | 135 | const HikesResource = createRestfulResource('hikes', { 136 | only: [RestfulResource.Routes.Index], 137 | }); 138 | 139 | HikesResource.index(); // /hikes 140 | HikesResource.edit() // throws an Error 141 | ``` 142 | 143 | ```js 144 | // javascript 145 | import { createRestfulResource } from '@hikerfeed/restful-resource'; 146 | 147 | const HikesResource = createRestfulResource('hikes', { 148 | only: ['index'], 149 | }); 150 | 151 | HikesResource.index(); // /hikes 152 | HikesResource.edit() // throws an Error 153 | ``` 154 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const presets = [['@babel/preset-env', { modules: false }]]; 3 | const plugins = ['@babel/plugin-transform-runtime']; 4 | 5 | return { 6 | plugins, 7 | presets, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /dist/config/index.d.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from './types'; 2 | export declare const paths: Record; 3 | -------------------------------------------------------------------------------- /dist/config/index.js: -------------------------------------------------------------------------------- 1 | const modelUrl = `/{model}/{id}`; 2 | export const paths = { 3 | create: `/{model}/create`, 4 | destroy: modelUrl, 5 | edit: `/{model}/{id}/edit`, 6 | index: `/{model}`, 7 | show: modelUrl, 8 | store: `/{model}`, 9 | update: modelUrl, 10 | }; 11 | -------------------------------------------------------------------------------- /dist/config/types.d.ts: -------------------------------------------------------------------------------- 1 | export declare namespace RestfulResource { 2 | interface API { 3 | create(id?: ModelId | ModelId[]): string; 4 | edit(id?: ModelId | ModelId[]): string; 5 | destroy(id?: ModelId | ModelId[]): string; 6 | index(id?: ModelId | ModelId[]): string; 7 | show(id?: ModelId | ModelId[]): string; 8 | store(id?: ModelId | ModelId[]): string; 9 | update(id?: ModelId | ModelId[]): string; 10 | } 11 | interface Options { 12 | only?: Routes[]; 13 | except?: Routes[]; 14 | } 15 | enum Routes { 16 | Create = "create", 17 | Edit = "edit", 18 | Destroy = "destroy", 19 | Index = "index", 20 | Show = "show", 21 | Store = "store", 22 | Update = "update" 23 | } 24 | type ModelId = number | string; 25 | } 26 | -------------------------------------------------------------------------------- /dist/config/types.js: -------------------------------------------------------------------------------- 1 | export var RestfulResource; 2 | (function (RestfulResource) { 3 | let Routes; 4 | (function (Routes) { 5 | Routes["Create"] = "create"; 6 | Routes["Edit"] = "edit"; 7 | Routes["Destroy"] = "destroy"; 8 | Routes["Index"] = "index"; 9 | Routes["Show"] = "show"; 10 | Routes["Store"] = "store"; 11 | Routes["Update"] = "update"; 12 | })(Routes = RestfulResource.Routes || (RestfulResource.Routes = {})); 13 | })(RestfulResource || (RestfulResource = {})); 14 | -------------------------------------------------------------------------------- /dist/create-restful-resource.d.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from './config/types'; 2 | export declare function createRestfulResource(modelName: string, options?: RestfulResource.Options): RestfulResource.API; 3 | -------------------------------------------------------------------------------- /dist/create-restful-resource.js: -------------------------------------------------------------------------------- 1 | import { createModelRoute } from './utils/create-model-route'; 2 | import { paths } from './config'; 3 | import { createNestedModelPrefix } from './utils/create-nested-model-prefix'; 4 | export function createRestfulResource(modelName, options = {}) { 5 | const methods = {}; 6 | let methodKeys = Object.keys(paths); 7 | if (options.only) { 8 | methodKeys = options.only; 9 | } 10 | else if (options.except) { 11 | methodKeys = methodKeys.filter(k => !options.except.includes(k)); 12 | } 13 | const { model, prefix } = createNestedModelPrefix(modelName); 14 | methodKeys.forEach(method => { 15 | methods[method] = function createRoute(id) { 16 | return createModelRoute({ 17 | id, 18 | model, 19 | path: paths[method], 20 | prefix, 21 | }); 22 | }; 23 | }); 24 | return new Proxy(methods, { 25 | get(target, propertyKey, receiver) { 26 | if (methodKeys.includes(propertyKey)) { 27 | return Reflect.get(target, propertyKey, receiver); 28 | } 29 | throw new Error(`No route was defined for ${propertyKey}(). Make sure to define it in options.`); 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export { createRestfulResource } from './create-restful-resource'; 2 | export { RestfulResource } from './config/types'; 3 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self)["restful-resource"]={})}(this,(function(e){"use strict";const t={create:"/{model}/create",destroy:"/{model}/{id}",edit:"/{model}/{id}/edit",index:"/{model}",show:"/{model}/{id}",store:"/{model}",update:"/{model}/{id}"};e.createRestfulResource=function(e,o={}){const r={};let i=Object.keys(t);o.only?i=o.only:o.except&&(i=i.filter(e=>!o.except.includes(e)));const{model:n,prefix:d}=function(e){if(/\./.test(e)){const t=e.split("."),o=[];for(let e=0;e{r[e]=function(o){return function({id:e,model:t,path:o,prefix:r}){let i=[r,o].filter(Boolean).join("/");return i=i.replace(/model/g,t),Array.isArray(e)?e.forEach(e=>{i=i.replace(/id/,`${e}`)}):i=void 0!==e?i.replace(/id/,e):i.replace(/id/g,""),i.replace(/\{|\}/g,"").replace(/\/\//g,"/").replace(/\/\//g,"/")}({id:o,model:n,path:t[e],prefix:d})}}),new Proxy(r,{get(e,t,o){if(i.includes(t))return Reflect.get(e,t,o);throw new Error(`No route was defined for ${t}(). Make sure to define it in options.`)}})},Object.defineProperty(e,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /dist/utils/create-model-route.d.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from '../config/types'; 2 | export declare function createModelRoute({ id, model, path, prefix, }: { 3 | id?: RestfulResource.ModelId | RestfulResource.ModelId[]; 4 | model: string; 5 | path: string; 6 | prefix?: string; 7 | }): string; 8 | -------------------------------------------------------------------------------- /dist/utils/create-model-route.js: -------------------------------------------------------------------------------- 1 | export function createModelRoute({ id, model, path, prefix, }) { 2 | let joinedPath = [prefix, path].filter(Boolean).join('/'); 3 | joinedPath = joinedPath.replace(/model/g, model); 4 | if (Array.isArray(id)) { 5 | id.forEach(k => { 6 | joinedPath = joinedPath.replace(/id/, `${k}`); 7 | }); 8 | } 9 | else if (typeof id !== 'undefined') { 10 | joinedPath = joinedPath.replace(/id/, id); 11 | } 12 | else { 13 | joinedPath = joinedPath.replace(/id/g, ''); 14 | } 15 | return joinedPath 16 | .replace(/\{|\}/g, '') 17 | .replace(/\/\//g, '/') 18 | .replace(/\/\//g, '/'); 19 | } 20 | -------------------------------------------------------------------------------- /dist/utils/create-nested-model-prefix.d.ts: -------------------------------------------------------------------------------- 1 | export interface NestedRoutes { 2 | prefix?: string; 3 | model: string; 4 | } 5 | export declare function createNestedModelPrefix(model: string): NestedRoutes; 6 | -------------------------------------------------------------------------------- /dist/utils/create-nested-model-prefix.js: -------------------------------------------------------------------------------- 1 | export function createNestedModelPrefix(model) { 2 | const childRegex = /\./; 3 | if (childRegex.test(model)) { 4 | const models = model.split('.'); 5 | const chunks = []; 6 | for (let i = 0; i < models.length - 1; i++) { 7 | chunks.push(`${models[i]}/{id}`); 8 | } 9 | return { 10 | model: models[models.length - 1], 11 | prefix: `/${chunks.join('/')}`, 12 | }; 13 | } 14 | return { 15 | model, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hikerfeed/restful-resource", 3 | "version": "1.0.2", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "release": "rm -rf dist/ && tsc -d && rollup -c", 9 | "lint": "eslint --ext .ts --ignore-path .eslintignore .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "prettier": "prettier '/**/*' --write", 12 | "unit": "jest src/ --maxWorkers 6", 13 | "unit:watch": "npm run unit -- --watch" 14 | }, 15 | "author": "hikerfeed", 16 | "private": false, 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@babel/core": "^7.7.2", 20 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 21 | "@babel/preset-env": "^7.7.1", 22 | "@types/jest": "^24.0.23", 23 | "@typescript-eslint/eslint-plugin": "1.4.2", 24 | "@typescript-eslint/parser": "1.4.2", 25 | "eslint": "5.9.0", 26 | "eslint-config-airbnb-base": "13.1.0", 27 | "eslint-config-prettier": "4.0.0", 28 | "eslint-friendly-formatter": "4.0.1", 29 | "eslint-loader": "2.1.1", 30 | "eslint-plugin-import": "2.14.0", 31 | "eslint-plugin-prettier": "2.6.2", 32 | "eslint-restricted-globals": "0.1.1", 33 | "jest": "^24.9.0", 34 | "prettier": "^1.19.1", 35 | "rollup": "^1.27.3", 36 | "rollup-plugin-alias": "^2.2.0", 37 | "rollup-plugin-babel": "^4.3.3", 38 | "rollup-plugin-terser": "^5.1.2", 39 | "rollup-plugin-typescript2": "^0.25.2", 40 | "ts-jest": "24.0.0", 41 | "ts-loader": "5.3.3", 42 | "ts-node": "8.0.3", 43 | "tslib": "^1.10.0", 44 | "typescript": "3.3.3333" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/hikerfeed/restful-resource.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/hikerfeed/restful-resource/issues" 52 | }, 53 | "homepage": "https://github.com/hikerfeed/restful-resource#readme", 54 | "jest": { 55 | "moduleFileExtensions": [ 56 | "js", 57 | "ts", 58 | "json" 59 | ], 60 | "preset": "ts-jest", 61 | "transform": { 62 | "^.+\\.ts$": "ts-jest", 63 | "^.+\\.js$": "babel-jest" 64 | }, 65 | "rootDir": "./src" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import alias from 'rollup-plugin-alias'; 2 | import babel from 'rollup-plugin-babel'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | 6 | export default { 7 | input: 'src/index.ts', 8 | 9 | output: { 10 | file: 'dist/index.js', 11 | format: 'umd', 12 | name: 'restful-resource', 13 | }, 14 | 15 | plugins: [ 16 | alias({ 17 | resolve: ['', '.mjs', '.js', '.ts'], 18 | }), 19 | 20 | babel({ 21 | exclude: 'node_modules/**', 22 | runtimeHelpers: true, 23 | }), 24 | 25 | typescript({ 26 | abortOnError: false, 27 | exclude: ['**/*.test.ts', 'test/**/*', 'dist/**/*'], 28 | }), 29 | 30 | terser(), 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from './types'; 2 | 3 | /** 4 | * Default model url. 5 | */ 6 | const modelUrl: string = `/{model}/{id}`; 7 | 8 | /** 9 | * A mapping of paths for the resource method. 10 | */ 11 | export const paths: Record = { 12 | create: `/{model}/create`, 13 | destroy: modelUrl, 14 | edit: `/{model}/{id}/edit`, 15 | index: `/{model}`, 16 | show: modelUrl, 17 | store: `/{model}`, 18 | update: modelUrl, 19 | }; 20 | -------------------------------------------------------------------------------- /src/config/types.ts: -------------------------------------------------------------------------------- 1 | export namespace RestfulResource { 2 | export interface API { 3 | create(id?: ModelId | ModelId[]): string; 4 | edit(id?: ModelId | ModelId[]): string; 5 | destroy(id?: ModelId | ModelId[]): string; 6 | index(id?: ModelId | ModelId[]): string; 7 | show(id?: ModelId | ModelId[]): string; 8 | store(id?: ModelId | ModelId[]): string; 9 | update(id?: ModelId | ModelId[]): string; 10 | } 11 | 12 | export interface Options { 13 | only?: Routes[]; 14 | except?: Routes[]; 15 | } 16 | 17 | export enum Routes { 18 | Create = 'create', 19 | Edit = 'edit', 20 | Destroy = 'destroy', 21 | Index = 'index', 22 | Show = 'show', 23 | Store = 'store', 24 | Update = 'update', 25 | } 26 | 27 | export type ModelId = number | string; 28 | } 29 | -------------------------------------------------------------------------------- /src/create-model-resource.test.ts: -------------------------------------------------------------------------------- 1 | import { createRestfulResource } from './create-restful-resource'; 2 | import { RestfulResource } from './config/types'; 3 | 4 | describe('restful resource', () => { 5 | it('should include all rest methods by default', () => { 6 | const model = createRestfulResource('user'); 7 | 8 | expect(model.index).not.toBeUndefined(); 9 | expect(model.create).not.toBeUndefined(); 10 | expect(model.store).not.toBeUndefined(); 11 | expect(model.show).not.toBeUndefined(); 12 | expect(model.edit).not.toBeUndefined(); 13 | expect(model.update).not.toBeUndefined(); 14 | expect(model.destroy).not.toBeUndefined(); 15 | }); 16 | 17 | it('should include only the methods specified with only', () => { 18 | const model = createRestfulResource('user', { 19 | only: [RestfulResource.Routes.Index, RestfulResource.Routes.Edit], 20 | }); 21 | 22 | expect(model.index).not.toBeUndefined(); 23 | expect(model.edit).not.toBeUndefined(); 24 | 25 | expect(() => model.create()).toThrow(); 26 | expect(() => model.store()).toThrow(); 27 | expect(() => model.show()).toThrow(); 28 | expect(() => model.update()).toThrow(); 29 | expect(() => model.destroy()).toThrow(); 30 | }); 31 | 32 | it('should exclude only the methods that are specified in except', () => { 33 | const model = createRestfulResource('user', { 34 | except: [RestfulResource.Routes.Show, RestfulResource.Routes.Update], 35 | }); 36 | 37 | expect(model.index).not.toBeUndefined(); 38 | expect(model.create).not.toBeUndefined(); 39 | expect(model.store).not.toBeUndefined(); 40 | expect(model.edit).not.toBeUndefined(); 41 | expect(model.destroy).not.toBeUndefined(); 42 | 43 | expect(() => model.update()).toThrow(); 44 | expect(() => model.show()).toThrow(); 45 | }); 46 | 47 | it('should create proper REST routes', () => { 48 | const model = createRestfulResource('users'); 49 | 50 | expect(model.index()).toBe('/users'); 51 | expect(model.create()).toBe('/users/create'); 52 | expect(model.store()).toBe('/users'); 53 | expect(model.show(3)).toBe('/users/3'); 54 | expect(model.edit(4)).toBe('/users/4/edit'); 55 | expect(model.update(5)).toBe('/users/5'); 56 | expect(model.destroy('5')).toBe('/users/5'); 57 | }); 58 | 59 | it('should create proper nested REST routes', () => { 60 | const model = createRestfulResource('users.photos'); 61 | 62 | expect(model.index(15)).toBe('/users/15/photos'); 63 | expect(model.create(2)).toBe('/users/2/photos/create'); 64 | expect(model.store(3)).toBe('/users/3/photos'); 65 | expect(model.show([3, 4])).toBe('/users/3/photos/4'); 66 | expect(model.edit([4, 5])).toBe('/users/4/photos/5/edit'); 67 | expect(model.update([5, 4])).toBe('/users/5/photos/4'); 68 | expect(model.destroy(['5', 200])).toBe('/users/5/photos/200'); 69 | }); 70 | 71 | it('should create proper nested REST routes', () => { 72 | const model = createRestfulResource('towns.ratings', { 73 | only: [RestfulResource.Routes.Index, RestfulResource.Routes.Update], 74 | }); 75 | 76 | expect(model.index(15)).toBe('/towns/15/ratings'); 77 | expect(model.update([5, 4])).toBe('/towns/5/ratings/4'); 78 | expect(model.index()).toBe('/towns/ratings'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/create-restful-resource.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from './config/types'; 2 | import { createModelRoute } from './utils/create-model-route'; 3 | import { paths } from './config'; 4 | import { createNestedModelPrefix } from './utils/create-nested-model-prefix'; 5 | 6 | /** 7 | * Create a restful resource 8 | */ 9 | export function createRestfulResource(modelName: string, options: RestfulResource.Options = {}): RestfulResource.API { 10 | const methods = {}; 11 | 12 | let methodKeys = Object.keys(paths); 13 | 14 | if (options.only) { 15 | methodKeys = options.only; 16 | } else if (options.except) { 17 | methodKeys = methodKeys.filter(k => !(options.except).includes(k)); 18 | } 19 | 20 | const { model, prefix } = createNestedModelPrefix(modelName); 21 | 22 | methodKeys.forEach(method => { 23 | methods[method] = function createRoute(id?: RestfulResource.ModelId | RestfulResource.ModelId[]): string { 24 | return createModelRoute({ 25 | id, 26 | model, 27 | path: paths[method], 28 | prefix, 29 | }); 30 | }; 31 | }); 32 | 33 | return new Proxy(methods, { 34 | get(target, propertyKey: RestfulResource.Routes, receiver) { 35 | if (methodKeys.includes(propertyKey)) { 36 | return Reflect.get(target, propertyKey, receiver); 37 | } 38 | 39 | throw new Error(`No route was defined for ${propertyKey}(). Make sure to define it in options.`); 40 | }, 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createRestfulResource } from './create-restful-resource'; 2 | export { RestfulResource } from './config/types'; 3 | -------------------------------------------------------------------------------- /src/utils/create-model-route.test.ts: -------------------------------------------------------------------------------- 1 | import { createModelRoute } from './create-model-route'; 2 | import { paths } from '../config'; 3 | import { createNestedModelPrefix } from './create-nested-model-prefix'; 4 | 5 | describe('createModelRoute', () => { 6 | it('return a model route', () => { 7 | const parsedRoute = createModelRoute({ 8 | id: 1, 9 | model: 'photos', 10 | path: paths.show, 11 | }); 12 | 13 | expect(parsedRoute).toEqual('/photos/1'); 14 | }); 15 | 16 | it('return a model route without an id', () => { 17 | const parsedRoute = createModelRoute({ 18 | model: 'photos', 19 | path: paths.index, 20 | }); 21 | 22 | expect(parsedRoute).toEqual('/photos'); 23 | }); 24 | 25 | it('return a model route with a prefix', () => { 26 | const parsedRoute = createModelRoute({ 27 | id: [1, 2], 28 | model: 'comments', 29 | path: paths.show, 30 | prefix: createNestedModelPrefix('photos.comments').prefix, 31 | }); 32 | 33 | expect(parsedRoute).toEqual('/photos/1/comments/2'); 34 | }); 35 | 36 | // check for each method 37 | it('return a model route with a prefix for create method', () => { 38 | const parsedRoute = createModelRoute({ 39 | model: 'comments', 40 | path: paths.create, 41 | }); 42 | 43 | expect(parsedRoute).toEqual('/comments/create'); 44 | }); 45 | 46 | it('return a model route with a prefix for destroy method', () => { 47 | const parsedRoute = createModelRoute({ 48 | id: 1, 49 | model: 'comments', 50 | path: paths.destroy, 51 | }); 52 | 53 | expect(parsedRoute).toEqual('/comments/1'); 54 | }); 55 | 56 | it('return a model route with a prefix for edit method', () => { 57 | const parsedRoute = createModelRoute({ 58 | id: 3, 59 | model: 'comments', 60 | path: paths.edit, 61 | }); 62 | 63 | expect(parsedRoute).toEqual('/comments/3/edit'); 64 | }); 65 | 66 | it('return a model route with a prefix for index method', () => { 67 | const parsedRoute = createModelRoute({ 68 | id: 1, 69 | model: 'comments', 70 | path: paths.index, 71 | prefix: createNestedModelPrefix('photos.comments').prefix, 72 | }); 73 | 74 | expect(parsedRoute).toEqual('/photos/1/comments'); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/utils/create-model-route.ts: -------------------------------------------------------------------------------- 1 | import { RestfulResource } from '../config/types'; 2 | 3 | /** 4 | * Create the model route. 5 | */ 6 | export function createModelRoute({ 7 | id, 8 | model, 9 | path, 10 | prefix, 11 | }: { 12 | id?: RestfulResource.ModelId | RestfulResource.ModelId[]; 13 | model: string; 14 | path: string; 15 | prefix?: string; 16 | }): string { 17 | let joinedPath = [prefix, path].filter(Boolean).join('/'); 18 | 19 | joinedPath = joinedPath.replace(/model/g, model); 20 | 21 | if (Array.isArray(id)) { 22 | id.forEach(k => { 23 | joinedPath = joinedPath.replace(/id/, `${k}`); 24 | }); 25 | } else if (typeof id !== 'undefined') { 26 | joinedPath = joinedPath.replace(/id/, id); 27 | } else { 28 | joinedPath = joinedPath.replace(/id/g, ''); 29 | } 30 | 31 | return joinedPath 32 | .replace(/\{|\}/g, '') 33 | .replace(/\/\//g, '/') 34 | .replace(/\/\//g, '/'); 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/create-nested-model-prefix.test.ts: -------------------------------------------------------------------------------- 1 | import { createNestedModelPrefix } from './create-nested-model-prefix'; 2 | 3 | describe('createNestedModelPrefix', () => { 4 | it('should return an empty string when no dot is present', () => { 5 | expect(createNestedModelPrefix('photos')).toEqual({ 6 | model: 'photos', 7 | }); 8 | }); 9 | 10 | it('should return a new path for the parent and child model', () => { 11 | expect(createNestedModelPrefix('photos.comments')).toEqual({ 12 | model: 'comments', 13 | prefix: '/photos/{id}', 14 | }); 15 | }); 16 | 17 | it('should return a new path for multiple routes', () => { 18 | expect(createNestedModelPrefix('foo.bar.baz.bong')).toEqual({ 19 | model: 'bong', 20 | prefix: '/foo/{id}/bar/{id}/baz/{id}', 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/create-nested-model-prefix.ts: -------------------------------------------------------------------------------- 1 | export interface NestedRoutes { 2 | prefix?: string; 3 | model: string; 4 | } 5 | 6 | /** 7 | * Create a nested model prefix for the given model. 8 | */ 9 | export function createNestedModelPrefix(model: string): NestedRoutes { 10 | const childRegex = /\./; 11 | 12 | if (childRegex.test(model)) { 13 | const models = model.split('.'); 14 | const chunks: string[] = []; 15 | 16 | for (let i = 0; i < models.length - 1; i++) { 17 | chunks.push(`${models[i]}/{id}`); 18 | } 19 | 20 | return { 21 | model: models[models.length - 1], 22 | prefix: `/${chunks.join('/')}`, 23 | }; 24 | } 25 | 26 | return { 27 | model, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowJs": false, 5 | "baseUrl": ".", 6 | "experimentalDecorators": false, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "importHelpers": true, 10 | "lib": [ 11 | "es2017", 12 | "dom", 13 | "dom.iterable", 14 | "scripthost" 15 | ], 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "noImplicitAny": false, 19 | "noImplicitReturns": true, 20 | "noUnusedLocals": true, 21 | "noEmitOnError": true, 22 | "noImplicitThis": false, 23 | "removeComments": true, 24 | "strictNullChecks": true, 25 | "suppressImplicitAnyIndexErrors": true, 26 | "sourceMap": false, 27 | "strict": true, 28 | "target": "es2017", 29 | "types": [ 30 | "node", 31 | "@types/jest" 32 | ], 33 | "outDir": "dist", 34 | "declaration": true 35 | }, 36 | "exclude": [ 37 | "node_modules", 38 | "**/*.test.ts", 39 | "*.test.ts", 40 | "test/**/*" 41 | ] 42 | } 43 | --------------------------------------------------------------------------------