├── .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 |
--------------------------------------------------------------------------------