├── .babelrc ├── .prettierrc ├── .eslintrc ├── dev ├── modules │ ├── settings.js │ ├── xhr.js │ ├── rest-client.js │ └── base-model.js ├── common │ └── helper.js └── main.js ├── webpack.config.js ├── LICENSE ├── .gitignore ├── package.json └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "bracketSpacing": true 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dev/modules/settings.js: -------------------------------------------------------------------------------- 1 | const settings = { 2 | timeout: 0, 3 | endpoints: {}, 4 | defaultEndpoint: '', 5 | apiPaths: { 6 | default: '', 7 | }, 8 | defaultApiPath: '', 9 | headers: { 10 | accept: 'application/json', 11 | 'Content-Type': 'application/json', 12 | }, 13 | setHeader: (key, value) => { 14 | settings.headers[key] = value; 15 | }, 16 | modelHeaders: {}, 17 | beforeEveryRequest: () => {}, 18 | afterEveryRequest: () => {}, 19 | }; 20 | module.exports = settings; 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | const BUILD_DIR = path.resolve(__dirname, 'dist'); 5 | const APP_DIR = path.resolve(__dirname, 'dev'); 6 | const prod = process.argv.indexOf('-p') !== -1; 7 | 8 | const config = { 9 | entry: path.resolve(APP_DIR, 'main.js'), 10 | output: { 11 | path: BUILD_DIR, 12 | filename: prod ? 'rest-in-model.min.js' : 'rest-in-model.js', 13 | library: 'rest-in-model', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true, 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.js?/, 21 | include: APP_DIR, 22 | use: 'babel-loader', 23 | }, 24 | ], 25 | }, 26 | }; 27 | 28 | module.exports = config; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 engin üstün 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | test_project 61 | dist 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-in-model", 3 | "version": "0.0.0", 4 | "description": "model based REST consumer library.", 5 | "main": "dist/rest-in-model.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack --progress --mode=development", 9 | "build:prod": "webpack -p --progress --mode=production" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/enginustun/rest-in-model.git" 14 | }, 15 | "keywords": [], 16 | "author": "engin üstün", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/enginustun/rest-in-model/issues" 20 | }, 21 | "homepage": "https://github.com/enginustun/rest-in-model#readme", 22 | "readme": "https://github.com/enginustun/rest-in-model/blob/master/README.md", 23 | "devDependencies": { 24 | "@babel/cli": "^7.0.0", 25 | "@babel/core": "^7.0.0", 26 | "@babel/plugin-proposal-class-properties": "^7.0.0", 27 | "@babel/plugin-proposal-decorators": "^7.0.0", 28 | "@babel/plugin-proposal-do-expressions": "^7.0.0", 29 | "@babel/plugin-proposal-export-default-from": "^7.0.0", 30 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0", 31 | "@babel/plugin-proposal-function-bind": "^7.0.0", 32 | "@babel/plugin-proposal-function-sent": "^7.0.0", 33 | "@babel/plugin-proposal-json-strings": "^7.0.0", 34 | "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0", 35 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", 36 | "@babel/plugin-proposal-numeric-separator": "^7.0.0", 37 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 38 | "@babel/plugin-proposal-optional-chaining": "^7.0.0", 39 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0", 40 | "@babel/plugin-proposal-throw-expressions": "^7.0.0", 41 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 42 | "@babel/plugin-syntax-import-meta": "^7.0.0", 43 | "@babel/preset-env": "^7.0.0", 44 | "babel-loader": "^8.0.0", 45 | "babel-plugin-transform-object-rest-spread": "^7.0.0-beta.3", 46 | "eslint": "^4.16.0", 47 | "eslint-plugin-import": "^2.8.0", 48 | "webpack": "^4.16.5", 49 | "webpack-cli": "^3.1.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /dev/modules/xhr.js: -------------------------------------------------------------------------------- 1 | import settings from './settings'; 2 | 3 | const isValid = value => { 4 | return value || value === undefined; 5 | }; 6 | 7 | class XHR { 8 | constructor() { 9 | this.xhr = new XMLHttpRequest(); 10 | this.xhr.timeout = settings.timeout; 11 | this.method = 'GET'; 12 | this.async = true; 13 | this.url = ''; 14 | this.data = {}; 15 | this.headers = {}; 16 | } 17 | 18 | setHeader(name, value) { 19 | this.headers[name] = value; 20 | } 21 | 22 | exec({ beforeRequest = () => {}, afterRequest = () => {} } = {}) { 23 | return new Promise((resolve, reject) => { 24 | this.xhr.open(this.method, this.url, this.async); 25 | const headerKeys = Object.keys(this.headers); 26 | for (let i = 0; i < headerKeys.length; i += 1) { 27 | this.xhr.setRequestHeader(headerKeys[i], this.headers[headerKeys[i]]); 28 | } 29 | this.xhr.onreadystatechange = () => { 30 | if (this.xhr.readyState === 4) { 31 | settings.afterEveryRequest(this.xhr); 32 | afterRequest(this.xhr); 33 | if (this.xhr.status >= 200 && this.xhr.status < 300) { 34 | try { 35 | const responseType = this.xhr.responseType.toLowerCase(); 36 | let data = this.xhr.response; 37 | if (['', 'text'].includes(responseType)) { 38 | const contentType = ( 39 | this.xhr.getResponseHeader('Content-Type') || '' 40 | ).toLowerCase(); 41 | if (data && contentType.includes('application/json')) { 42 | data = JSON.parse(this.xhr.responseText); 43 | } 44 | } 45 | resolve(data); 46 | } catch (error) { 47 | reject(error); 48 | } 49 | } else { 50 | try { 51 | reject(JSON.parse(this.xhr.responseText || this.xhr.statusText)); 52 | } catch (error) { 53 | reject(new Error(this.xhr.statusText)); 54 | } 55 | } 56 | } 57 | }; 58 | isValid(settings.beforeEveryRequest(this.xhr)) && 59 | isValid(beforeRequest(this.xhr)) && 60 | this.xhr.send(this.data); 61 | }); 62 | } 63 | } 64 | 65 | export default XHR; 66 | -------------------------------------------------------------------------------- /dev/common/helper.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isObject: val => Object.prototype.toString.call(val) === '[object Object]', 3 | 4 | isArray: val => Object.prototype.toString.call(val) === '[object Array]', 5 | 6 | isFunction: val => 7 | Object.prototype.toString.call(val) === '[object Function]', 8 | 9 | defaultFunction: () => {}, 10 | 11 | pathJoin: (...paths) => { 12 | const pathArray = Array.prototype.slice.call(paths); 13 | let resultPath = ''; 14 | for (let i = 0; i < pathArray.length; i += 1) { 15 | let pathItem = pathArray[i]; 16 | if (pathItem) { 17 | const pathItemLength = pathItem.length; 18 | if (pathItemLength > 0) { 19 | if (pathItem[pathItemLength - 1] === '/') { 20 | pathItem = pathItem.substr(0, pathItemLength - 1); 21 | } 22 | if (pathItem[0] === '/') { 23 | pathItem = pathItem.substr(1, pathItemLength - 1); 24 | } 25 | } 26 | resultPath += (resultPath.length > 0 && pathItem ? '/' : '') + pathItem; 27 | } 28 | } 29 | return resultPath; 30 | }, 31 | 32 | replaceUrlParamsWithValues: (url, paramValues = {}) => { 33 | const paramKeys = Object.keys(paramValues); 34 | let newurl = url; 35 | for (let i = 0; i < paramKeys.length; i += 1) { 36 | const paramKey = paramKeys[i]; 37 | newurl = newurl.replace( 38 | `{${paramKey}}`, 39 | encodeURIComponent(paramValues[paramKey]) 40 | ); 41 | } 42 | return newurl; 43 | }, 44 | 45 | appendQueryParamsToUrl: (url, queryParams) => { 46 | let newurl = url; 47 | if (queryParams) { 48 | const paramKeys = Object.keys(queryParams); 49 | for (let i = 0; i < paramKeys.length; i += 1) { 50 | const paramKey = paramKeys[i]; 51 | if ( 52 | queryParams[paramKey] !== undefined && 53 | queryParams[paramKey] !== null 54 | ) { 55 | newurl += `${ 56 | newurl.indexOf('?') === -1 ? '?' : '' 57 | }${paramKey}=${encodeURIComponent(queryParams[paramKey])}${ 58 | i < paramKeys.length - 1 ? '&' : '' 59 | }`; 60 | } 61 | } 62 | } 63 | return newurl; 64 | }, 65 | 66 | getFormData: object => { 67 | let formData = new FormData(); 68 | for (const key in object) { 69 | if (object.hasOwnProperty(key)) { 70 | if (object[key] !== undefined && object[key] !== null) { 71 | formData.append(key, object[key]); 72 | } 73 | } 74 | } 75 | return formData; 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /dev/modules/rest-client.js: -------------------------------------------------------------------------------- 1 | import settings from './settings'; 2 | import XHR from './xhr'; 3 | import helper from '../common/helper'; 4 | 5 | const setHeaders = (request, headers) => { 6 | if (request instanceof XHR) { 7 | const modelHeaderKeys = Object.keys(headers); 8 | for (let i = 0; i < modelHeaderKeys.length; i += 1) { 9 | const headerKey = modelHeaderKeys[i]; 10 | if ( 11 | headerKey.toLowerCase() !== 'content-type' || 12 | !headers[headerKey].toLowerCase().includes('form-data') 13 | ) { 14 | request.setHeader(headerKey, headers[headerKey]); 15 | } 16 | } 17 | } 18 | }; 19 | 20 | const getFormData = (contentType, data) => { 21 | let formData = data; 22 | if (contentType.includes('form-data')) { 23 | formData = helper.getFormData(data); 24 | } else if (contentType.includes('application/json')) { 25 | formData = JSON.stringify(formData); 26 | } else if (contentType.includes('x-www-form-urlencoded')) { 27 | formData = helper.appendQueryParamsToUrl('', formData); 28 | formData = formData.substr(1, formData.length); 29 | } 30 | return formData; 31 | }; 32 | 33 | class RestClient { 34 | constructor(_settings) { 35 | this.settings = {}; 36 | if (helper.isObject(_settings)) { 37 | this.settings.endpoint = 38 | settings.endpoints[_settings.endpointName || settings.defaultEndpoint]; 39 | this.settings.apiPath = 40 | settings.apiPaths[_settings.apiPathName || settings.defaultApiPath]; 41 | } 42 | } 43 | 44 | sendRequest({ method = 'GET', service, data = null, headers = {} }) { 45 | const request = new XHR(); 46 | headers = { 47 | ...settings.headers, 48 | ...headers 49 | } 50 | setHeaders(request, headers); 51 | request.method = method; 52 | request.url = helper.pathJoin( 53 | this.settings.endpoint, 54 | this.settings.apiPath, 55 | service 56 | ); 57 | if (data) { 58 | request.data = getFormData( 59 | (headers['Content-Type'] || '').toLowerCase(), 60 | data 61 | ); 62 | } else { 63 | delete request.data; 64 | } 65 | return request; 66 | } 67 | 68 | get(service, headers) { 69 | return this.sendRequest({ service, headers }); 70 | } 71 | 72 | post(service, data, headers) { 73 | return this.sendRequest({ method: 'POST', service, data, headers }); 74 | } 75 | 76 | put(service, data, headers) { 77 | return this.sendRequest({ method: 'PUT', service, data, headers }); 78 | } 79 | 80 | patch(service, data, headers) { 81 | return this.sendRequest({ method: 'PATCH', service, data, headers }); 82 | } 83 | 84 | delete(service, data, headers) { 85 | return this.sendRequest({ method: 'DELETE', service, data, headers }); 86 | } 87 | } 88 | 89 | export default RestClient; 90 | -------------------------------------------------------------------------------- /dev/main.js: -------------------------------------------------------------------------------- 1 | import RestClient from './modules/rest-client'; 2 | import RestBaseModel from './modules/base-model'; 3 | import _settings from './modules/settings'; 4 | import helper from './common/helper'; 5 | 6 | const settings = { 7 | addEndpoint: endpoint => { 8 | if (helper.isObject(endpoint) && endpoint.name && endpoint.value) { 9 | _settings.endpoints[endpoint.name] = endpoint.value; 10 | if (endpoint.default) { 11 | if (_settings.defaultEndpoint) { 12 | throw new Error('There can be only one default endpoint'); 13 | } 14 | _settings.defaultEndpoint = endpoint.name; 15 | } 16 | } else if (helper.isArray(endpoint)) { 17 | endpoint.map(item => { 18 | if (item.name && item.value) { 19 | _settings.endpoints[item.name] = item.value; 20 | if (item.default) { 21 | if (_settings.defaultEndpoint) { 22 | throw new Error('There can be only one default endpoint'); 23 | } 24 | _settings.defaultEndpoint = item.name; 25 | } 26 | } 27 | }); 28 | } else { 29 | throw new Error( 30 | 'Endpoint provided is not valid or its format is wrong. Correct format is { name = "", value = "" }.' 31 | ); 32 | } 33 | }, 34 | addApiPath: apiPath => { 35 | if (helper.isObject(apiPath) && apiPath.name && apiPath.value) { 36 | _settings.apiPaths[apiPath.name] = apiPath.value; 37 | } else if (helper.isArray(apiPath)) { 38 | apiPath.map(item => { 39 | if (item.name && item.value) { 40 | _settings.apiPaths[item.name] = item.value; 41 | if (item.default) { 42 | if (_settings.defaultApiPath) { 43 | throw new Error('There can be only one default api path'); 44 | } 45 | _settings.defaultApiPath = item.name; 46 | } 47 | } 48 | }); 49 | } else { 50 | throw new Error( 51 | 'ApiPaths provided is not valid or its format is wrong. Correct format is { name = "", value = "" }.' 52 | ); 53 | } 54 | }, 55 | setDefaultEndpoint: endpointName => { 56 | if (endpointName && typeof endpointName === 'string') { 57 | if (_settings.endpoints[endpointName]) { 58 | _settings.defaultEndpoint = endpointName; 59 | } else { 60 | throw new Error( 61 | 'Endpoint name provided must be added to endpoints before.' 62 | ); 63 | } 64 | } else { 65 | throw new Error( 66 | 'Default endpoint name must be provided and its type must be string.' 67 | ); 68 | } 69 | }, 70 | setDefaultApiPath: apiPathName => { 71 | if (apiPathName && typeof apiPathName === 'string') { 72 | if (_settings.apiPaths[apiPathName]) { 73 | _settings.defaultApiPath = apiPathName; 74 | } else { 75 | throw new Error( 76 | 'API path name provided must be added to ApiPaths before.' 77 | ); 78 | } 79 | } else { 80 | throw new Error( 81 | 'Default api path name must be provided and its type must be string.' 82 | ); 83 | } 84 | }, 85 | setTimeout: duration => { 86 | _settings.timeout = duration; 87 | }, 88 | setHeader: _settings.setHeader, 89 | set beforeEveryRequest(func = helper.defaultFunction) { 90 | _settings.beforeEveryRequest = func; 91 | }, 92 | set afterEveryRequest(func = helper.defaultFunction) { 93 | _settings.afterEveryRequest = func; 94 | }, 95 | set: (configs = {}) => { 96 | configs.endpoints && settings.addEndpoint(configs.endpoints); 97 | configs.apiPaths && settings.addApiPath(configs.apiPaths); 98 | configs.defaultEndpoint && 99 | settings.setDefaultEndpoint(configs.defaultEndpoint); 100 | configs.defaultApiPath && 101 | settings.setDefaultApiPath(configs.defaultApiPath); 102 | configs.timeout && settings.setTimeout(configs.timeout); 103 | helper.isObject(configs.headers) && 104 | Object.entries(configs.headers).map(config => 105 | settings.setHeader(config[0], config[1]) 106 | ); 107 | if (configs.beforeEveryRequest) { 108 | settings.beforeEveryRequest = beforeEveryRequest; 109 | } 110 | if (configs.afterEveryRequest) { 111 | settings.afterEveryRequest = afterEveryRequest; 112 | } 113 | }, 114 | }; 115 | 116 | export { RestClient, RestBaseModel, settings }; 117 | -------------------------------------------------------------------------------- /dev/modules/base-model.js: -------------------------------------------------------------------------------- 1 | import RestClient from './rest-client'; 2 | import helper from '../common/helper'; 3 | 4 | const getConsumerOptions = (opt, config) => ({ 5 | endpointName: opt.endpointName || config.endpointName, 6 | apiPathName: opt.apiPathName || config.apiPathName, 7 | }); 8 | 9 | class RestBaseModel { 10 | constructor(...[_model, ...args]) { 11 | const model = _model || {}; 12 | const { constructor } = this; 13 | const config = this.getConfig(); 14 | const { fields } = config; 15 | 16 | if (helper.isObject(fields)) { 17 | const fieldKeys = Object.keys(fields); 18 | const serializers = {}; 19 | for (let i = 0; i < fieldKeys.length; i += 1) { 20 | const fieldKey = fieldKeys[i]; 21 | if (helper.isFunction(fields[fieldKey].serializer)) { 22 | serializers[fieldKey] = fields[fieldKey].serializer; 23 | } else { 24 | const ModelClass = fields[fieldKey].modelClass; 25 | const fieldValue = 26 | model[fields[fieldKey].map] === undefined 27 | ? model[fieldKey] 28 | : model[fields[fieldKey].map]; 29 | if (ModelClass && ModelClass.prototype instanceof RestBaseModel) { 30 | this[fieldKey] = new ModelClass(fieldValue, ...args); 31 | } else { 32 | this[fieldKey] = fieldValue; 33 | } 34 | if (this[fieldKey] === undefined && fields[fieldKey]) { 35 | if (helper.isArray(fields[fieldKey].default)) { 36 | this[fieldKey] = []; 37 | } else if (helper.isObject(fields[fieldKey].default)) { 38 | this[fieldKey] = {}; 39 | } else if (fields[fieldKey].default !== undefined) { 40 | this[fieldKey] = fields[fieldKey].default; 41 | } 42 | } 43 | } 44 | } 45 | Object.entries(serializers).forEach(([fieldKey, serializer]) => { 46 | this[fieldKey] = serializer(...args); 47 | }); 48 | } 49 | 50 | // define REST consumer 51 | if (!constructor.consumer) { 52 | Object.defineProperty(constructor, 'consumer', { 53 | value: new RestClient(getConsumerOptions({}, config)), 54 | writable: true, 55 | }); 56 | } 57 | } 58 | 59 | getConfig() { 60 | return { 61 | fields: {}, 62 | }; 63 | } 64 | 65 | getIdField() { 66 | const { prototype } = this.constructor; 67 | const config = prototype.getConfig(); 68 | if (!prototype.hasOwnProperty('_idField')) { 69 | const { fields } = config; 70 | Object.defineProperty(prototype, '_idField', { 71 | value: Object.keys(fields).find(fieldKey => { 72 | return fields[fieldKey].primary; 73 | }), 74 | enumerable: false, 75 | configurable: false, 76 | writable: false, 77 | }); 78 | } 79 | return prototype._idField; 80 | } 81 | 82 | save(options) { 83 | return this.constructor.save({ ...options, model: this }); 84 | } 85 | 86 | static save(options) { 87 | const opt = options || {}; 88 | if (!(opt.model instanceof this)) { 89 | throw Error('model must be instance of RestBaseModel'); 90 | } 91 | 92 | const { prototype } = this; 93 | const _idField = prototype.getIdField(); 94 | const config = prototype.getConfig(); 95 | const id = opt.model[_idField]; 96 | const { fields } = config; 97 | const consumer = new RestClient(getConsumerOptions(opt, config)); 98 | const path = opt.path || 'default'; 99 | 100 | return new Promise((resolve, reject) => { 101 | let request; 102 | const isPost = !id; 103 | const isPatch = Array.isArray(opt.patch); 104 | const isPut = !isPost && !isPatch; 105 | const requestType = isPost ? 'post' : isPatch ? 'patch' : 'put'; 106 | const requestData = {}; 107 | const fieldKeys = opt.patch || Object.keys(opt.model); 108 | for (let i = 0; i < fieldKeys.length; i += 1) { 109 | const key = fieldKeys[i]; 110 | requestData[(fields[key] || {}).map || key] = opt.model[key]; 111 | } 112 | (isPost || isPut) && delete requestData[_idField]; 113 | 114 | request = consumer[requestType]( 115 | helper.replaceUrlParamsWithValues( 116 | opt.disableAutoAppendedId 117 | ? config.paths[path] 118 | : helper.pathJoin(config.paths[path], encodeURIComponent(id || '')), 119 | opt.pathData 120 | ), 121 | opt.data || requestData, 122 | config.headers || {} 123 | ); 124 | if (opt.generateOnly) { 125 | resolve({ requestURL: request.url }); 126 | } else { 127 | request 128 | .exec(opt) 129 | .then(response => { 130 | if (isPost) { 131 | opt.model[_idField] = 132 | response[(fields[_idField] || {}).map || _idField]; 133 | } 134 | resolve({ response, request: request.xhr }); 135 | }) 136 | .catch(response => { 137 | reject({ response, request: request.xhr }); 138 | }); 139 | } 140 | }); 141 | } 142 | 143 | static get(options) { 144 | const opt = options || {}; 145 | const config = this.prototype.getConfig(); 146 | const consumer = new RestClient(getConsumerOptions(opt, config)); 147 | const path = opt.path || 'default'; 148 | opt.pathData = opt.pathData || {}; 149 | opt.resultField = opt.resultField || config.resultField; 150 | 151 | return new Promise((resolve, reject) => { 152 | let resultPath = config.paths[path]; 153 | const { id } = opt; 154 | if (id && !opt.disableAutoAppendedId) { 155 | // if there is no pathData.id it should be set 156 | opt.pathData.id = opt.pathData.id || id; 157 | if (path === 'default') { 158 | resultPath = helper.pathJoin(config.paths[path], '{id}'); 159 | } 160 | } 161 | // replace url parameters and append query parameters 162 | resultPath = helper.appendQueryParamsToUrl( 163 | helper.replaceUrlParamsWithValues(resultPath, opt.pathData), 164 | opt.queryParams 165 | ); 166 | const request = consumer.get(resultPath, config.headers || {}); 167 | if (opt.generateOnly) { 168 | resolve({ requestURL: request.url }); 169 | } else { 170 | request 171 | .exec(opt) 172 | .then(response => { 173 | let model; 174 | if (helper.isObject(response)) { 175 | if (helper.isFunction(opt.resultField)) { 176 | model = opt.resultField(response); 177 | } else { 178 | model = new this( 179 | opt.resultField && response[opt.resultField] 180 | ? response[opt.resultField] 181 | : response, 182 | ...(opt.constructorArgs || []) 183 | ); 184 | } 185 | } 186 | resolve({ model, response, request: request.xhr }); 187 | }) 188 | .catch(response => { 189 | reject({ response, request: request.xhr }); 190 | }); 191 | } 192 | }); 193 | } 194 | 195 | static all(options) { 196 | const config = this.prototype.getConfig(); 197 | const opt = options || {}; 198 | const consumer = new RestClient(getConsumerOptions(opt, config)); 199 | const path = opt.path || 'default'; 200 | opt.pathData = opt.pathData || {}; 201 | opt.resultListField = opt.resultListField || config.resultListField; 202 | 203 | return new Promise((resolve, reject) => { 204 | let resultPath = helper.replaceUrlParamsWithValues( 205 | config.paths[path], 206 | opt.pathData 207 | ); 208 | // replace url parameters and append query parameters 209 | resultPath = helper.appendQueryParamsToUrl( 210 | helper.replaceUrlParamsWithValues(resultPath, opt.pathData), 211 | opt.queryParams 212 | ); 213 | const request = consumer.get(resultPath, config.headers || {}); 214 | if (opt.generateOnly) { 215 | resolve({ requestURL: request.url }); 216 | } else { 217 | request 218 | .exec(opt) 219 | .then(response => { 220 | if (!helper.isArray(opt.resultList)) { 221 | opt.resultList = []; 222 | } 223 | let list; 224 | if (helper.isFunction(opt.resultListField)) { 225 | list = opt.resultListField(response); 226 | } else { 227 | list = 228 | opt.resultListField && 229 | helper.isArray(response[opt.resultListField]) 230 | ? response[opt.resultListField] 231 | : response; 232 | } 233 | opt.resultList.length = 0; 234 | if (helper.isArray(list)) { 235 | for (let i = 0; i < list.length; i += 1) { 236 | const item = list[i]; 237 | const ClassName = 238 | opt.resultListItemType && 239 | opt.resultListItemType.prototype instanceof RestBaseModel 240 | ? opt.resultListItemType 241 | : this; 242 | helper.isObject(item) && 243 | opt.resultList.push( 244 | new ClassName(item, ...(opt.constructorArgs || [])) 245 | ); 246 | } 247 | } 248 | resolve({ 249 | resultList: opt.resultList, 250 | response, 251 | request: request.xhr, 252 | }); 253 | }) 254 | .catch(response => { 255 | reject({ response, request: request.xhr }); 256 | }); 257 | } 258 | }); 259 | } 260 | 261 | delete(options) { 262 | const opt = options || {}; 263 | const { prototype } = this.constructor; 264 | const _idField = prototype.getIdField(); 265 | const id = opt.id || this[_idField]; 266 | return this.constructor.delete({ ...opt, id }); 267 | } 268 | 269 | static delete(options) { 270 | const opt = options || {}; 271 | const { prototype } = this; 272 | const config = prototype.getConfig(); 273 | const { id } = opt; 274 | 275 | const consumer = new RestClient(getConsumerOptions(opt, config)); 276 | const path = opt.path || 'default'; 277 | 278 | return new Promise((resolve, reject) => { 279 | try { 280 | const request = consumer.delete( 281 | helper.replaceUrlParamsWithValues( 282 | opt.disableAutoAppendedId 283 | ? config.paths[path] 284 | : helper.pathJoin( 285 | config.paths[path], 286 | encodeURIComponent(id || '') 287 | ), 288 | opt.pathData 289 | ), 290 | opt.data, 291 | config.headers || {} 292 | ); 293 | if (opt.generateOnly) { 294 | resolve({ requestURL: request.url }); 295 | } else { 296 | request 297 | .exec(opt) 298 | .then(response => { 299 | resolve({ response, request: request.xhr }); 300 | }) 301 | .catch(response => { 302 | reject({ response, request: request.xhr }); 303 | }); 304 | } 305 | } catch (error) { 306 | reject(error); 307 | } 308 | }); 309 | } 310 | } 311 | 312 | export default RestBaseModel; 313 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rest-in-model 2 | 3 | model based REST consumer library. 4 | 5 | ## Installing 6 | 7 | `npm install rest-in-model` 8 | 9 | ## Usage 10 | ### Imports 11 | ``` javascript 12 | // full import 13 | import RestInModel from 'rest-in-model'; 14 | 15 | // or you can import by destructuring 16 | import { RestClient, RestBaseModel, settings } from 'rest-in-model'; 17 | ``` 18 | ### Settings 19 | ##### Endpoint configuration 20 | ``` javascript 21 | // Add single endpoint 22 | settings.addEndpoint({ name: 'project', value: 'https://jsonplaceholder.typicode.com/' }); 23 | // Add multiple endpoint. And in this case can be only one default endpoint, otherwise throw error 24 | settings.addEndpoint([ 25 | { 26 | name: 'api', 27 | value: 'https://jsonplaceholder.typicode.com/', 28 | default: true, 29 | }, 30 | { 31 | name: 'projects', 32 | value: 'https://jsonplaceholder.typicode.com/projects', 33 | }, 34 | ]); 35 | 36 | // set default endpoint uses that cases: if either not given default endpoint or desired to change default endpoint 37 | settings.setDefaultEndpoint('api'); 38 | ``` 39 | ##### Api Paths configurations 40 | ``` javascript 41 | // Add single api path 42 | settings.addApiPath({ name: 'auth', value: '/auth' }); 43 | settings.addApiPath({ name: 'api', value: '/serve' }); 44 | // Add multiple api path. And in this case can be only one default api path, otherwise throw error 45 | // true 46 | settings.addApiPath([ 47 | { 48 | name: 'auth', 49 | value: '/auth', 50 | default: true, 51 | }, 52 | { 53 | name: 'api', 54 | value: '/serve', 55 | }, 56 | ]); 57 | // false 58 | settings.addApiPath([ 59 | { 60 | name: 'auth', 61 | value: '/auth', 62 | default: true, 63 | }, 64 | { 65 | name: 'api', 66 | value: '/serve', 67 | default: true, // Not Correct 68 | }, 69 | ]); 70 | // set default api path uses that cases: if either not given default api path or desired to change default api path 71 | settings.setDefaultApiPath('api'); 72 | ``` 73 | 74 | ##### Set constant header configuration for each requests 75 | ``` javascript 76 | // set common headers for all models with setHeader method of settings 77 | settings.setHeader('Authorization', 'JWT xxxxxxxxxxxxxxxxxxxx...'); 78 | ``` 79 | 80 | > You can add additional model-specific headers to each model in `getConfig` method while defining model config. See `User` model definition below: 81 | 82 | ### Model Definition 83 | 84 | **User** model: 85 | 86 | ``` javascript 87 | import { RestBaseModel } from 'rest-in-model'; 88 | 89 | class User extends RestBaseModel { 90 | getConfig() { 91 | return { 92 | // you can add additional headers 93 | headers: { 94 | 'Content-Type': 'multipart/form-data' 95 | }, 96 | fields: { 97 | id: { primary: true }, 98 | name: {}, 99 | username: { /*map: 'user_name'*/ }, 100 | email: {}, 101 | company: {}, 102 | phone: { /*default: null*/ }, 103 | website: {}, 104 | address: {} 105 | } 106 | 107 | // Normally you don't need to do this. But sometimes back-end doesn't/can't give what you want... 108 | // You can get any child from response as result list to convert if you need. 109 | resultListField: (response) => response.result.contents, 110 | 111 | paths: { 112 | default: 'users', 113 | posts: 'users/{userId}/posts' 114 | }, 115 | 116 | //endpointName: '', 117 | //apiPathName: '', 118 | }; 119 | } 120 | } 121 | 122 | module.exports = User; 123 | ``` 124 | 125 | **Post** model: 126 | 127 | ``` javascript 128 | import { RestBaseModel } from 'rest-in-model'; 129 | 130 | class Post extends RestBaseModel { 131 | getConfig() { 132 | return { 133 | fields: { 134 | id: { primary: true }, 135 | title: {}, 136 | body: {}, 137 | userId: {} 138 | }, 139 | 140 | paths: { 141 | default: 'posts', 142 | userPosts: 'users/{userId}/posts' 143 | }, 144 | 145 | //endpointName: '', 146 | //apiPathName: '', 147 | } 148 | } 149 | } 150 | 151 | module.exports = Post; 152 | ``` 153 | 154 | ### Methods 155 | 156 | ```javascript 157 | import User from '{model_folder}/user'; 158 | 159 | const userInstance = new User(); 160 | 161 | // all returns Promise 162 | userInstance.save(options); 163 | User.save(options); 164 | 165 | userInstance.delete(options); 166 | User.delete(options); 167 | 168 | User.get(options); 169 | User.all(options); 170 | ``` 171 | 172 | ### Detailed Explanation 173 | 174 | **userInstance.save(options);** 175 | 176 | `options: { path, patch }` 177 | 178 | |Property|Description|Type|Default Value| 179 | |--------|-----------|----|--------| 180 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 181 | |patch(optional)|array of model fields that need to be updated with patch request|`string[]`|-| 182 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 183 | 184 | ```javascript 185 | userInstance.save(); // userInstance.id === undefined 186 | // XHR finished loading: POST "https://jsonplaceholder.typicode.com/users" 187 | 188 | userInstance.save(); // userInstance.id !== undefined 189 | // XHR finished loading: PUT "https://jsonplaceholder.typicode.com/users/(:userId)" 190 | 191 | userInstance.save({ patch: ['name', 'lastname'] }); // userInstance.id !== undefined 192 | // XHR finished loading: PATCH "https://jsonplaceholder.typicode.com/users/(:userId)" 193 | ``` 194 | 195 | --- 196 | **User.save(options);** 197 | 198 | `options: { model, path, patch }` 199 | 200 | |Property|Description|Type|Default Value| 201 | |--------|-----------|----|--------| 202 | |model(required)|instance of Model extended from RestBaseModel|`instance of RestBaseModel`|-| 203 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 204 | |patch(optional)|array of model fields that need to be updated with patch request|`string[]`|-| 205 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 206 | 207 | ``` javascript 208 | const userInstance = new User({ 209 | name: 'engin üstün', 210 | username: 'enginustun', 211 | email: 'enginustun@outlook.com', 212 | company: '-', 213 | phone: '-', 214 | website: '-', 215 | address: '-' 216 | }); 217 | 218 | User.save({ model: userInstance }).then((response) => { 219 | // response is original server response 220 | // userInstance.id === id of saved record 221 | }); 222 | // XHR finished loading: POST "https://jsonplaceholder.typicode.com/users" 223 | ``` 224 | 225 | --- 226 | **userInstance.delete(options);** 227 | 228 | `options: { path }` 229 | 230 | |Property|Description|Type|Default Value| 231 | |--------|-----------|----|--------| 232 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 233 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 234 | 235 | ``` javascript 236 | userInstance.delete(); // userInstance.id !== undefined 237 | // XHR finished loading: DELETE "https://jsonplaceholder.typicode.com/users/(:userId)" 238 | 239 | userInstance.delete({ id: 4 }); // userInstance.id doesn't matter 240 | // XHR finished loading: DELETE "https://jsonplaceholder.typicode.com/users/4" 241 | ``` 242 | 243 | --- 244 | **User.delete(options);** 245 | 246 | `options: { id, path }` 247 | 248 | |Property|Description|Type|Default Value| 249 | |--------|-----------|----|--------| 250 | |id(required)|required id parameter of model will be deleted. if it is not provided, there will be an error thrown|`number\|string`|-| 251 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 252 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 253 | 254 | ``` javascript 255 | User.delete(); // throws an error 256 | 257 | User.delete({ id: 4 }); 258 | // XHR finished loading: DELETE "https://jsonplaceholder.typicode.com/users/4" 259 | ``` 260 | 261 | --- 262 | **User.get(options);** 263 | 264 | `options: { id, path, pathData, queryParams }` 265 | 266 | |Property|Description|Type|Default Value| 267 | |--------|-----------|----|--------| 268 | |id(required)|required id parameter of model will be requested from server. if it is not provided, there will be an error thrown|`number\|string`|-| 269 | |resultField(optional)|**Better to define as config in model class for related field(s)**

for string: if the response is an object, result will be converted based on this name from the response. if this property is not provided or 'response[resultField]' is a falsy value, the response will be assumed as model itself and result will be converted from the response directly.

for function: you can return any of child/sub-child of given response object.|`string\|(response) => response.what.you.need`|-| 270 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 271 | |pathData(optional)|object that contains values of variables in path specified|`object`|-| 272 | |queryParams(optional)|object that contains keys and values of query parameters|`object`|-| 273 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 274 | 275 | ``` javascript 276 | User.get(); // throws an error 277 | 278 | User.get({ id: 2 }).then(({ model, response }) => { 279 | // 'model' parameter is an instance of User that automatically converted from the server's response. 280 | // 'response' parameter is original response which is coming from server. 281 | }); 282 | // XHR finished loading: GET "https://jsonplaceholder.typicode.com/users/2" 283 | 284 | // see User.all() function for usage of pathData property. 285 | ``` 286 | 287 | --- 288 | **User.all(options);** 289 | 290 | `options: { resultList, resultListField, resultListItemType, path, pathData, queryParams }` 291 | 292 | |Property|Description|Type|Default Value| 293 | |--------|-----------|----|--------| 294 | |resultList(optional)|array object that will be filled models into it|`[]` reference|-| 295 | |resultListField(optional)|**Better to define as config in model class for related field(s)**

for string: if the response is an object, result list will be converted based on this name from the response. if this property is not provided or 'response[resultListField]' is not an array, the response will be assumed as model list itself and result will be converted from the response directly.

for function: you can return any of child/sub-child of given response object.|`string\|(response) => response.what.you.need`|-| 296 | |resultListItemType(optional)|it needs to be provided if the result type is different from class type itself.|a model class that is inherited from 'RestBaseModel'|itself| 297 | |path(optional)|one of the path attribute name in paths object defined in model|`string`|default| 298 | |pathData(optional)|object that contains values of variables in path specified|`object`|-| 299 | |queryParams(optional)|object that contains keys and values of query parameters|`object`|-| 300 | |generateOnly(optional)|if you want to generate only request url, use this property|`boolean`|`false`| 301 | 302 | ``` javascript 303 | User.all().then(({ resultList, response }) => { 304 | // 'resultList' parameter is array of model which is converted from response 305 | // 'response' parameter is original server response 306 | }); 307 | // XHR finished loading: GET "https://jsonplaceholder.typicode.com/users" 308 | 309 | // recommended usage is above 310 | var list = []; 311 | User.all({ resultList: list }).then(() => { 312 | // 'list' array will be filled with models which are received from server's response. 313 | }); 314 | // XHR finished loading: GET "https://jsonplaceholder.typicode.com/users" 315 | 316 | // path, pathData and resultListItemType usage 317 | User.all({ path: 'posts', pathData: { userId: 2 }, resultListItemType: Post }).then(({ resultList, response }) => { 318 | // 'resultList' parameter is an array of Post instances which belongs to user which has id=2. 319 | }); 320 | // XHR finished loading: GET "https://jsonplaceholder.typicode.com/users/2/posts" 321 | // it works properly but this way is not recommended. 322 | 323 | // best practice of this usage is below 324 | Post.all({ path: 'userPosts', pathData: { userId: 2 } }).then(({ resultList, response }) => { 325 | // 'resultList' parameter is an array of Post instances which belongs to user which has id=2. 326 | }); 327 | // sends same request as above 328 | // XHR finished loading: GET "https://jsonplaceholder.typicode.com/users/2/posts" 329 | ``` 330 | --------------------------------------------------------------------------------