├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── NOTICE ├── README.md ├── babel.config.js ├── jest.config.js ├── licenses ├── javascript-algorithms.txt └── plugin-axios.txt ├── package.json ├── scripts └── release.js ├── spec ├── feature │ ├── RelationTransformers.js │ ├── Requests.js │ ├── RestfulActions.js │ ├── StiRelations.js │ └── VuexOrmJsonApi.js ├── models │ ├── AdultsInitializer.js │ ├── ChildrenInitializer.js │ ├── GroupsInitializer.js │ ├── HousesInitializer.js │ ├── InhabitablesInitializer.js │ ├── MeetingsInitializer.js │ ├── ModelFactory.js │ ├── MonstersInitializer.js │ ├── OfficesInitializer.js │ ├── PeopleInitializer.js │ ├── ToysInitializer.js │ ├── UserProfileAttributesInitializer.js │ ├── UserProfilesInitializer.js │ ├── UsersGroupsInitializer.js │ └── UsersInitializer.js └── support │ ├── spec_helper.js │ └── topological_sort.js ├── src ├── InsertionStore.js ├── JsonApiError.js ├── Utils.js ├── VuexOrmJsonApi.js ├── constants.js ├── index.js ├── json_api │ ├── Request.js │ └── Response.js ├── mixins │ ├── ModelMixin.js │ └── RestfulActionsMixin.js └── transformers │ ├── AttributeTransformer.js │ ├── BelongsToRelationTransformer.js │ ├── DocumentTransformer.js │ ├── FieldTransformer.js │ ├── HasManyByRelationTransformer.js │ ├── HasManyRelationTransformer.js │ ├── HasManyThroughRelationTransformer.js │ ├── HasOneRelationTransformer.js │ ├── ModelTransformer.js │ ├── MorphToRelationTransformer.js │ └── RelationTransformer.js ├── webpack.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | ], 11 | globals: { 12 | Atomics: 'readonly', 13 | SharedArrayBuffer: 'readonly', 14 | }, 15 | parser: 'babel-eslint', 16 | parserOptions: { 17 | sourceType: 'module', 18 | }, 19 | rules: { 20 | /* Dangling commas enforce consistency and make diffs easy on the eye. */ 21 | 'comma-dangle': [ 22 | 'error', 23 | 'always-multiline', 24 | ], 25 | /* Disallows weird comma placement. */ 26 | 'comma-spacing': [ 27 | 'error', 28 | { 29 | before: false, 30 | after: true, 31 | }, 32 | ], 33 | /* 34 | * Curly braces denote new basic blocks. Since control flow statements lead to Turing completeness, keywords like 35 | * `if` statements should be clearly delineated. 36 | */ 37 | curly: [ 38 | 'error', 39 | 'all', 40 | ], 41 | /* Files should end with a newline. */ 42 | 'eol-last': [ 43 | 'error', 44 | 'always', 45 | ], 46 | /* If you are using type coercion of any kind with `==`, you're not in control of the code. */ 47 | 'eqeqeq': [ 48 | 'error', 49 | 'always', 50 | ], 51 | /* Four spaces is too extravagant. */ 52 | indent: [ 53 | 'error', 54 | 2, 55 | { 56 | /* 57 | * `switch` cases need another level of indentation since they might have their own lexical scope and `switch` 58 | * itself demands curly braces. 59 | */ 60 | SwitchCase: 1, 61 | /* A workaround for `https://github.com/babel/babel-eslint/issues/681`. */ 62 | ignoredNodes: ['TemplateLiteral'], 63 | }, 64 | ], 65 | /* Improves readability of keywords. */ 66 | 'keyword-spacing': [ 67 | 'error', 68 | {after: true}, 69 | ], 70 | /* We work on Unix-like systems. Also, Windows is weird and non-compliant. */ 71 | 'linebreak-style': [ 72 | 'error', 73 | 'unix', 74 | ], 75 | /* 76 | * This is a multiline starred block comment. This is a multiline starred block comment. This is a multiline starred 77 | * block comment. This is a multiline starred block comment. This is a multiline starred block comment. This is a... 78 | */ 79 | 'multiline-comment-style': [ 80 | 'error', 81 | 'starred-block', 82 | ], 83 | /* Helps the user identify stray `console.log`s. */ 84 | 'no-console': [ 85 | 'error', 86 | {allow: ['warn', 'error']}, 87 | ], 88 | /* Disallows too many empty lines. */ 89 | 'no-multiple-empty-lines': [ 90 | 'error', 91 | { 92 | max: 2, 93 | maxBOF: 0, 94 | maxEOF: 0, 95 | }, 96 | ], 97 | /* Disallows trailing spaces outside of comments. */ 98 | 'no-trailing-spaces': [ 99 | 'error', 100 | {ignoreComments: true}, 101 | ], 102 | /* Disallows unused variables. */ 103 | 'no-unused-vars': [ 104 | 'error', 105 | { 106 | argsIgnorePattern: '^_', 107 | varsIgnorePattern: '^_', 108 | }, 109 | ], 110 | /* 111 | * Disallows spaces inside of curly braces for consistency. 112 | */ 113 | 'object-curly-spacing': [ 114 | 'error', 115 | 'never', 116 | ], 117 | /* Use of multiline declarations in curly braces should have consistent spacing. */ 118 | 'object-curly-newline': [ 119 | 'error', 120 | {consistent: true}, 121 | ], 122 | /* 123 | * Single and double quotes are equivalent in JavaScript, except using single quotes frees us up to not escape 124 | * double-quoted HTML attribute values in strings. See 125 | * `https://www.reddit.com/r/javascript/comments/4m715v/should_i_use_or/d3tpk1o`. 126 | */ 127 | 'quotes': [ 128 | 'error', 129 | 'single', 130 | ], 131 | /* Semicolons terminate statements, and they should do just that. */ 132 | semi: [ 133 | 'error', 134 | 'always', 135 | ], 136 | /* Semicolons should appear at the end of statements. */ 137 | 'semi-style': [ 138 | 'error', 139 | 'last', 140 | ], 141 | /* Control flow statements used to delineate new basic blocks should be treated care: See the `curly` rule. */ 142 | 'space-before-blocks': [ 143 | 'error', 144 | 'always', 145 | ], 146 | /* 147 | * Parentheses should be separated from keywords in the case of `function () {}` and `async () => {}` for 148 | * readability. 149 | */ 150 | 'space-before-function-paren': [ 151 | 'error', 152 | { 153 | anonymous: 'always', 154 | named: 'never', 155 | asyncArrow: 'always', 156 | }, 157 | ], 158 | /* 159 | * Disallows spaces inside of parentheses for consistency. 160 | */ 161 | 'space-in-parens': [ 162 | 'error', 163 | 'never', 164 | ], 165 | }, 166 | }; 167 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.tmp 2 | /coverage 3 | /dist 4 | /node_modules 5 | /temp 6 | .idea/* 7 | .vscode/* 8 | yarn-error.log 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.0.0 (2024-08-02) 2 | 3 | - Introduce semantically richer exceptions in `JsonApiError`, which wraps the Axios error as its cause and provides 4 | access to the `response` object. 5 | 6 | ### 0.9.9 (2022-11-01) 7 | 8 | - Add the `rawRequest` API. 9 | 10 | ### 0.9.8 (2021-09-10) 11 | 12 | - Make the library more HMR friendly. 13 | - Clarify configuration of REST actions' underlying axios object. 14 | 15 | ### 0.9.7 (2020-11-01) 16 | 17 | - Handle STI correctly when pivot models are involved in `BelongsToMany`, `MorphByMany`, and `MorphToMany` relations. 18 | - Introduce STI unit tests. 19 | - Fix an issue with type checking. 20 | - Fix an issue with the `InsertionStore`. 21 | 22 | ### 0.9.6 (2020-08-27) 23 | 24 | - Add `null` value checks for to-one transformers. 25 | - Use `Promise.all` on an array of Vuex ORM mutator promises instead of `await` on individual promises in a `for` loop. 26 | 27 | ### 0.9.5 (2020-05-25) 28 | 29 | - Public release. 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | vuex-orm-json-api 2 | Copyright 2020 Roy Liu 3 | 4 | ... with components from: 5 | 6 | * plugin-axios (see `licenses/plugin-axios.txt`). 7 | * javascript-algorithms (see `licenses/javascript-algorithms.txt`). 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vuex ORM JSON:API - A JSON:API adapter for Vuex ORM 2 | 3 | Vuex ORM JSON:API is a Vuex ORM library plugin for reading the JSON:API format. It contains mechanisms for transforming 4 | the JSON:API specification's flexible, self-describing documents, resources, attributes, and relationships into their 5 | Vuex ORM analogues of records, attributes, and relations. Also included are RESTful actions that extend the 6 | [Vuex ORM Axios](https://github.com/vuex-orm/plugin-axios) plugin's HTTP actions. 7 | 8 | ### Installation 9 | 10 | 1. Installing via NPM: 11 | 12 | ``` 13 | npm install --save vuex-orm-json-api 14 | ``` 15 | 16 | Installing via Yarn: 17 | 18 | ``` 19 | yarn add vuex-orm-json-api 20 | ``` 21 | 22 | 2. Importing the plugin: 23 | 24 | ``` 25 | import axios from 'axios'; 26 | import VuexORM from '@vuex-orm/core'; 27 | import VuexOrmJsonApi, {RestfulActionsMixin} from 'vuex-orm-json-api'; 28 | 29 | VuexORM.use(VuexOrmJsonApi, {axios, mixins: [RestfulActionsMixin]}); 30 | ``` 31 | 32 | ### Usage 33 | 34 | Assuming that you've included the `RestfulActionsMixin`, the adapter supports five, Ruby on Rails-y RESTful actions: 35 | 36 | ``` 37 | User.jsonApi().index(); // Get all users. 38 | User.jsonApi().show(1); // Get user 1. 39 | User.jsonApi().create({name: 'Harry Bovik'}); // Create a user. 40 | User.jsonApi().update(1, {name: 'Harry Q. Bovik'}); // Update user 1. 41 | User.jsonApi().destroy(1); // Destroy user 1. 42 | ``` 43 | 44 | Some considerations: 45 | 46 | * Because JSON:API documents are divided into **primary data** (`data: {}` or `data: []`) and **related resources** 47 | (`included: []`), all actions come with the side effect of upserting received resources, via `Model.insertOrUpdate`, 48 | into the Vuex ORM database (or deleting in case of `destroy`). 49 | * The return value of each action is the upserted record(s) corresponding to the primary data section, with proper 50 | multiplicity: `Array` for `index`; `Object` for `show`, `create`, and `update`; and `null` for `destroy`. 51 | * `index`, `show`, `create`, `update`, and `destroy` respectively use the `GET`, `GET`, `POST`, `PATCH`, and `DELETE` 52 | HTTP methods. The JSON:API specification seems to prefer `PATCH` over `PUT`. 53 | * You may pass in a `scope` function to qualify returned records further. For example: 54 | 55 | ``` 56 | await User.jsonApi().show(1, {scope: (query) => query.with('users.group')}); 57 | ``` 58 | * Additional options beyond the required action parameters will be pass through to the underlying 59 | [axios request](https://axios-http.com/docs/api_intro). Potential configurations include `url` and `method`. 60 | * If you want to dig into JSON:API top-level properties like `meta` and `links` (see 61 | https://jsonapi.org/format/#document-top-level), use the `rawRequest` API like so: 62 | 63 | ``` 64 | const vuexOrmJsonApiResponse = await User.jsonApi().rawRequest({method: 'get', url: '/api/users/1'}); 65 | const theMeta = vuexOrmJsonApiResponse.meta; 66 | // Commit to the database. 67 | await vuexOrmJsonApiResponse.commit(); 68 | ``` 69 | 70 | --- 71 | 72 | The adapter may be configured at installation time 73 | 74 | ``` 75 | VuexORM.use(VuexOrmJsonApi, {axios, mixins: [RestfulActionsMixin], apiRoot: '/'}); 76 | ``` 77 | 78 | or at the model level: 79 | 80 | ``` 81 | class User extends Model { 82 | static jsonApiConfig = { 83 | apiRoot: '/' 84 | } 85 | } 86 | ``` 87 | 88 | Configuration options include: 89 | 90 | * `apiRoot` - The path prefix to your API routes. The default is `/api`. 91 | * `resourceToEntityCase` - The converter from JSON:API resource casing to Vuex ORM model casing. The default is a 92 | simple kebab-to-snake case function, because the JSON:API specification prefers names like `users-groups` and Vuex 93 | ORM prefers names like `users_groups`. 94 | * `entityToResourceRouteCase` - The converter from Vue ORM model casing to resource route casing. The default is a 95 | no-op because this author writes Ruby on Rails API servers, which happen to also use plural snake case. 96 | 97 | ### Examples 98 | 99 | At a fundamental level, the adapter comprehends Vuex ORM relations and transforms the `relationships` found in JSON:API 100 | documents into insertion-ready data. Consider the following models: 101 | 102 | ``` 103 | class User extends Model { 104 | static entity = 'users' 105 | 106 | static fields() { 107 | return { 108 | id: this.attr(null), 109 | name: this.attr(null), 110 | 111 | groups: this.belongsToMany(Group, UsersGroup, 'user_id', 'group_id'), 112 | }; 113 | } 114 | } 115 | 116 | class Group extends Model { 117 | static entity = 'groups' 118 | 119 | static fields() { 120 | return { 121 | id: this.attr(null), 122 | name: this.attr(null), 123 | 124 | users: this.belongsToMany(User, UsersGroup, 'group_id', 'user_id'), 125 | }; 126 | } 127 | } 128 | 129 | class UsersGroup extends Model { 130 | static entity = 'users_groups' 131 | 132 | static primaryKey = ['user_id', 'group_id'] 133 | 134 | static fields() { 135 | return { 136 | user_id: this.attr(null), 137 | group_id: this.attr(null), 138 | }; 139 | } 140 | } 141 | ``` 142 | 143 | The somewhat complex setup above involves two models with bidirectional many-to-many relations and an intermediate model 144 | with composite primary key. Upon making the request 145 | 146 | ``` 147 | await User.jsonApi().show(1); 148 | ``` 149 | 150 | and receiving the response 151 | 152 | ``` 153 | { 154 | data: { 155 | id: 1, 156 | type: 'users', 157 | attributes: { 158 | name: 'Harry Bovik', 159 | }, 160 | relationships: { 161 | groups: { 162 | data: [ 163 | {id: 1, type: 'groups'}, 164 | ], 165 | }, 166 | }, 167 | }, 168 | included: [ 169 | {id: 1, type: 'groups', attributes: {name: 'CMU'}}, 170 | ], 171 | } 172 | ``` 173 | 174 | the state of the Vuex store is what we would expect, composite primary keys and all: 175 | 176 | ``` 177 | { 178 | users: { 179 | 1: {$id: '1', id: 1, name: 'Harry Bovik', users_groups: [], groups: []}, 180 | }, 181 | groups: { 182 | 1: {$id: '1', id: 1, name: 'CMU', users_groups: [], users: []}, 183 | }, 184 | users_groups: { 185 | '[1,1]': {$id: '[1,1]', id: null, user_id: 1, user: null, group_id: 1, group: null}, 186 | }, 187 | } 188 | ``` 189 | 190 | The JSON:API specification allows for self-describing, intrinsically polymorphic relations. How about those? 191 | 192 | ``` 193 | class Monster extends Model { 194 | static entity = 'monsters' 195 | 196 | static fields() { 197 | return { 198 | id: this.attr(null), 199 | name: this.attr(null), 200 | 201 | scaree_id: this.attr(null), 202 | scaree_type: this.attr(null), 203 | scaree: this.morphTo('scaree_id', 'scaree_type'), 204 | }; 205 | } 206 | } 207 | 208 | class Child extends Model { 209 | static entity = 'children' 210 | 211 | static fields() { 212 | return { 213 | id: this.attr(null), 214 | name: this.attr(null), 215 | 216 | monster_in_the_closet: this.morphOne(Monster, 'scaree_id', 'scaree_type'), 217 | }; 218 | } 219 | } 220 | ``` 221 | 222 | Upon making the request 223 | 224 | ``` 225 | await Child.jsonApi().show(1); 226 | ``` 227 | 228 | and receiving the response 229 | 230 | ``` 231 | { 232 | data: { 233 | id: 1, 234 | type: 'children', 235 | attributes: { 236 | name: 'Boo', 237 | }, 238 | relationships: { 239 | 'monster-in-the-closet': { 240 | data: {id: 1, type: 'monsters'}, 241 | }, 242 | }, 243 | }, 244 | included: [ 245 | {id: 1, type: 'monsters', attributes: {name: 'Sully'}}, 246 | ], 247 | } 248 | ``` 249 | 250 | the query 251 | 252 | ``` 253 | Child.query().whereId(1).with('monster_in_the_closet').first(); 254 | ``` 255 | 256 | will return Boo along with her monster friend Sully. 257 | 258 | The adapter transforms all types of Vuex ORM relations except for `HasManyThrough`. In the many-to-many example above, 259 | it takes extra care to ensure that records with composite primary keys have those component `*_id` attributes assigned 260 | before insertion. As a result, Vuex ORM can maintain its own concept of primary key in the internal `$id` attribute 261 | independently of the API server's usage of the `id` attribute in JSON:API resources. 262 | 263 | ### License 264 | 265 | Copyright 2020 Roy Liu 266 | 267 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 268 | use this file except in compliance with the License. You may obtain a copy 269 | of the License at 270 | 271 | http://www.apache.org/licenses/LICENSE-2.0 272 | 273 | Unless required by applicable law or agreed to in writing, software 274 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 275 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 276 | License for the specific language governing permissions and limitations 277 | under the License. 278 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'], 3 | plugins: [ 4 | [ 5 | require('@babel/plugin-proposal-class-properties').default, 6 | { 7 | loose: true, 8 | }, 9 | ], 10 | [ 11 | require('@babel/plugin-proposal-private-methods').default, 12 | { 13 | 'loose': true, 14 | }, 15 | ], 16 | [ 17 | require('@babel/plugin-transform-runtime').default, 18 | { 19 | helpers: false, 20 | regenerator: true, 21 | corejs: false, 22 | }, 23 | ], 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: __dirname, 3 | clearMocks: true, 4 | coverageDirectory: 'coverage', 5 | moduleNameMapper: { 6 | '^@/(.*)$': '/src/$1', 7 | '^spec/(.*)$': '/spec/$1', 8 | }, 9 | testMatch: ['/spec/feature/**/*.js'], 10 | }; 11 | -------------------------------------------------------------------------------- /licenses/javascript-algorithms.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Minko Gechev 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 | -------------------------------------------------------------------------------- /licenses/plugin-axios.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kia Ishii 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-orm-json-api", 3 | "version": "1.0.0", 4 | "description": "A JSON:API adapter for Vuex ORM.", 5 | "module": "src/index.js", 6 | "scripts": { 7 | "watch": "webpack --watch --mode=production", 8 | "build": "webpack --mode=production", 9 | "lint": "eslint --fix -- \"{src,spec}/**/*.js\" \"*.js\"", 10 | "test": "jest", 11 | "release": "node -- scripts/release.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/scalient/vuex-orm-json-api.git" 16 | }, 17 | "keywords": [ 18 | "vue", 19 | "vuex", 20 | "vuex-plugin", 21 | "vuex-orm", 22 | "vuex-orm-plugin", 23 | "axios", 24 | "json-api" 25 | ], 26 | "author": "Roy Liu", 27 | "license": "Apache-2.0", 28 | "jest": { 29 | "transform": { 30 | "^.+\\.jsx?$": "babel-jest" 31 | } 32 | }, 33 | "peerDependencies": { 34 | "@vuex-orm/core": ">=0.36.0" 35 | }, 36 | "dependencies": { 37 | "axios": "latest" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "latest", 41 | "@babel/plugin-transform-runtime": "latest", 42 | "@babel/preset-env": "latest", 43 | "@vuex-orm/core": ">=0.36.0", 44 | "axios-mock-adapter": "latest", 45 | "babel-eslint": "latest", 46 | "eslint": "latest", 47 | "jest": "latest", 48 | "vue": "latest", 49 | "vuex": "latest", 50 | "webpack": "latest" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const chalk = require('chalk'); 3 | const execa = require('execa'); 4 | 5 | const bin = (name) => path.resolve(__dirname, `../node_modules/.bin/${name}`); 6 | const run = (bin, args, opts = {}) => execa(bin, args, {stdio: 'inherit', ...opts}); 7 | const step = (msg) => console.log(chalk.cyan(msg)); 8 | 9 | async function main() { 10 | // Run tests before release. 11 | step('\nRunning tests...'); 12 | await run(bin('jest'), ['--clearCache']); 13 | await run('yarn', ['lint']); 14 | await run('yarn', ['test']); 15 | 16 | // Clean the Git repository. 17 | step('\nCleaning the Git repository...'); 18 | await run('git', ['clean', '-fd']); 19 | 20 | // Build the package. 21 | step('\nBuilding the package...'); 22 | await run('yarn', ['build']); 23 | 24 | // Publish the package. 25 | step('\nPublishing the package...'); 26 | await run('yarn', ['publish']); 27 | } 28 | 29 | main().catch((err) => console.error(err)); 30 | -------------------------------------------------------------------------------- /spec/feature/RelationTransformers.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import {describe, it, beforeEach, afterEach, expect} from '@jest/globals'; 4 | import {createStore, assertState} from 'spec/support/spec_helper'; 5 | import ModelFactory from '../models/ModelFactory'; 6 | import Utils from '../../src/Utils'; 7 | 8 | describe('Feature - Relation Transformers', () => { 9 | let mock; 10 | 11 | beforeEach(() => { 12 | mock = new MockAdapter(axios); 13 | }); 14 | 15 | afterEach(() => { 16 | mock.reset(); 17 | }); 18 | 19 | it('transforms the `BelongsTo` relation', async () => { 20 | const store = createStore(ModelFactory.presets); 21 | const {users_groups: UsersGroup} = store.$db().models(); 22 | 23 | mock.onGet('/api/users_groups/1').reply(200, { 24 | data: { 25 | id: 1, 26 | type: 'users-groups', 27 | relationships: { 28 | user: { 29 | data: {id: 1, type: 'users'}, 30 | }, 31 | group: { 32 | data: {id: 1, type: 'groups'}, 33 | }, 34 | }, 35 | }, 36 | included: [ 37 | {id: 1, type: 'users', attributes: {name: 'Harry Bovik'}}, 38 | {id: 1, type: 'groups', attributes: {name: 'CMU'}}, 39 | ], 40 | }); 41 | 42 | await UsersGroup.jsonApi().show(1); 43 | 44 | // This is also testing that the explicit `id` mandated by JSON:API can coexist with the true, composite key `$id`. 45 | assertState(store, { 46 | users: { 47 | 1: { 48 | $id: '1', 49 | id: 1, 50 | name: 'Harry Bovik', 51 | users_groups: [], 52 | groups: [], 53 | user_profile: null, 54 | user_profile_attributes: [], 55 | embedded_group_ids: null, 56 | embedded_groups: [], 57 | }, 58 | }, 59 | groups: { 60 | 1: {$id: '1', id: 1, name: 'CMU', users_groups: [], users: []}, 61 | }, 62 | users_groups: { 63 | '[1,1]': {$id: '[1,1]', id: 1, user_id: 1, user: null, group_id: 1, group: null}, 64 | }, 65 | }); 66 | }); 67 | 68 | it('transforms the `HasMany` relation', async () => { 69 | const store = createStore(ModelFactory.presets); 70 | const {users: User} = store.$db().models(); 71 | 72 | mock.onGet('/api/users/1').reply(200, { 73 | data: { 74 | id: 1, 75 | type: 'users', 76 | attributes: { 77 | name: 'Harry Bovik', 78 | }, 79 | relationships: { 80 | 'users-groups': { 81 | data: [ 82 | {id: 1, type: 'users-groups'}, 83 | ], 84 | }, 85 | }, 86 | }, 87 | included: [ 88 | { 89 | id: 1, type: 'users-groups', 90 | relationships: { 91 | group: { 92 | data: {id: 1, type: 'groups'}, 93 | }, 94 | }, 95 | }, 96 | {id: 1, type: 'groups', attributes: {name: 'CMU'}}, 97 | ], 98 | }); 99 | 100 | await User.jsonApi().show(1); 101 | 102 | /* 103 | * This is also testing `InsertionStore`'s ability to make the `UsersGroup` record available for relational 104 | * manipulation by both the `User` and `Group` record, thus correctly populating the `user_id` and `group_id` 105 | * attributes, thus ensuring that the `['user_id', 'group_id']` primary key can be generated. 106 | */ 107 | assertState(store, { 108 | users: { 109 | 1: { 110 | $id: '1', 111 | id: 1, 112 | name: 'Harry Bovik', 113 | users_groups: [], 114 | groups: [], 115 | user_profile: null, 116 | user_profile_attributes: [], 117 | embedded_group_ids: null, 118 | embedded_groups: [], 119 | }, 120 | }, 121 | groups: { 122 | 1: {$id: '1', id: 1, name: 'CMU', users_groups: [], users: []}, 123 | }, 124 | users_groups: { 125 | '[1,1]': {$id: '[1,1]', id: 1, user_id: 1, user: null, group_id: 1, group: null}, 126 | }, 127 | }); 128 | }); 129 | 130 | it('transforms the `HasManyBy` relation', async () => { 131 | const store = createStore(ModelFactory.presets); 132 | const {users: User} = store.$db().models(); 133 | 134 | mock.onGet('/api/users/1').reply(200, { 135 | data: { 136 | id: 1, 137 | type: 'users', 138 | attributes: { 139 | name: 'Harry Bovik', 140 | }, 141 | relationships: { 142 | embedded_groups: { 143 | data: [ 144 | {id: 1, type: 'groups'}, 145 | {id: 2, type: 'groups'}, 146 | {id: 3, type: 'groups'}, 147 | ], 148 | }, 149 | }, 150 | }, 151 | included: [ 152 | {id: 1, type: 'groups', attributes: {name: 'A'}}, 153 | {id: 2, type: 'groups', attributes: {name: 'B'}}, 154 | {id: 3, type: 'groups', attributes: {name: 'C'}}, 155 | ], 156 | }); 157 | 158 | await User.jsonApi().show(1); 159 | 160 | assertState(store, { 161 | users: { 162 | 1: { 163 | $id: '1', 164 | id: 1, 165 | name: 'Harry Bovik', 166 | users_groups: [], 167 | groups: [], 168 | user_profile: null, 169 | user_profile_attributes: [], 170 | embedded_group_ids: [1, 2, 3], 171 | embedded_groups: [], 172 | }, 173 | }, 174 | groups: { 175 | 1: {$id: '1', id: 1, name: 'A', users_groups: [], users: []}, 176 | 2: {$id: '2', id: 2, name: 'B', users_groups: [], users: []}, 177 | 3: {$id: '3', id: 3, name: 'C', users_groups: [], users: []}, 178 | }, 179 | }); 180 | }); 181 | 182 | it('transforms the `BelongsToMany` relation', async () => { 183 | const store = createStore(ModelFactory.presets); 184 | const {users: User} = store.$db().models(); 185 | 186 | mock.onGet('/api/users/1').reply(200, { 187 | data: { 188 | id: 1, 189 | type: 'users', 190 | attributes: { 191 | name: 'Harry Bovik', 192 | }, 193 | relationships: { 194 | groups: { 195 | data: [ 196 | {id: 1, type: 'groups'}, 197 | {id: 2, type: 'groups'}, 198 | {id: 3, type: 'groups'}, 199 | ], 200 | }, 201 | }, 202 | }, 203 | included: [ 204 | {id: 1, type: 'groups', attributes: {name: 'A'}}, 205 | {id: 2, type: 'groups', attributes: {name: 'B'}}, 206 | {id: 3, type: 'groups', attributes: {name: 'C'}}, 207 | ], 208 | }); 209 | 210 | await User.jsonApi().show(1); 211 | 212 | assertState(store, { 213 | users: { 214 | 1: { 215 | $id: '1', 216 | id: 1, 217 | name: 'Harry Bovik', 218 | users_groups: [], 219 | groups: [], 220 | user_profile: null, 221 | user_profile_attributes: [], 222 | embedded_group_ids: null, 223 | embedded_groups: [], 224 | }, 225 | }, 226 | groups: { 227 | 1: {$id: '1', id: 1, name: 'A', users_groups: [], users: []}, 228 | 2: {$id: '2', id: 2, name: 'B', users_groups: [], users: []}, 229 | 3: {$id: '3', id: 3, name: 'C', users_groups: [], users: []}, 230 | }, 231 | users_groups: { 232 | '[1,1]': {$id: '[1,1]', id: null, user_id: 1, user: null, group_id: 1, group: null}, 233 | '[1,2]': {$id: '[1,2]', id: null, user_id: 1, user: null, group_id: 2, group: null}, 234 | '[1,3]': {$id: '[1,3]', id: null, user_id: 1, user: null, group_id: 3, group: null}, 235 | }, 236 | }); 237 | }); 238 | 239 | it('transforms the `HasOne` relation', async () => { 240 | const store = createStore(ModelFactory.presets); 241 | const {users: User} = store.$db().models(); 242 | 243 | mock.onGet('/api/users/1').reply(200, { 244 | data: { 245 | id: 1, 246 | type: 'users', 247 | attributes: { 248 | name: 'Harry Bovik', 249 | }, 250 | relationships: { 251 | 'user-profile': { 252 | data: { 253 | id: 1, 254 | type: 'user-profiles', 255 | }, 256 | }, 257 | }, 258 | }, 259 | included: [ 260 | {id: 1, type: 'user-profiles'}, 261 | ], 262 | }); 263 | 264 | await User.jsonApi().show(1); 265 | 266 | assertState(store, { 267 | users: { 268 | 1: { 269 | $id: '1', 270 | id: 1, 271 | name: 'Harry Bovik', 272 | users_groups: [], 273 | groups: [], 274 | user_profile: null, 275 | user_profile_attributes: [], 276 | embedded_group_ids: null, 277 | embedded_groups: [], 278 | }, 279 | }, 280 | user_profiles: { 281 | 1: {$id: '1', id: 1, user_id: 1, user: null}, 282 | }, 283 | }); 284 | }); 285 | 286 | it('refuses to transform the `HasManyThrough` relation', async () => { 287 | const store = createStore(ModelFactory.presets); 288 | const {users: User} = store.$db().models(); 289 | 290 | mock.onGet('/api/users/1').reply(200, { 291 | data: { 292 | id: 1, 293 | type: 'users', 294 | attributes: { 295 | name: 'Harry Bovik', 296 | }, 297 | relationships: { 298 | 'user-profile-attributes': { 299 | data: [ 300 | { 301 | id: 1, 302 | type: 'user-profile-attributes', 303 | }, 304 | ], 305 | }, 306 | }, 307 | }, 308 | included: [ 309 | {id: 1, type: 'user-profile-attributes'}, 310 | ], 311 | }); 312 | 313 | await expect(User.jsonApi().show(1)).rejects.toThrow( 314 | Utils.error('Writing directly to a `HasManyThrough` relation is not supported'), 315 | ); 316 | }); 317 | 318 | it('transforms the `morphToMany` relation', async () => { 319 | const store = createStore(ModelFactory.presets); 320 | const {offices: Office} = store.$db().models(); 321 | 322 | mock.onGet('/api/offices').reply(200, { 323 | data: [ 324 | { 325 | id: 1, 326 | type: 'offices', 327 | attributes: { 328 | name: 'Newell Simon Hall', 329 | }, 330 | relationships: { 331 | people: { 332 | data: [ 333 | {id: 1, type: 'adults'}, 334 | {id: 2, type: 'adults'}, 335 | {id: 3, type: 'adults'}, 336 | ], 337 | }, 338 | }, 339 | }, 340 | { 341 | id: 2, 342 | type: 'offices', 343 | attributes: { 344 | name: 'Wean Hall', 345 | }, 346 | relationships: { 347 | people: { 348 | data: [ 349 | {id: 3, type: 'adults'}, 350 | ], 351 | }, 352 | }, 353 | }, 354 | ], 355 | included: [ 356 | {id: 1, type: 'adults', attributes: {name: 'Allen Newell'}}, 357 | {id: 2, type: 'adults', attributes: {name: 'Herb Simon'}}, 358 | {id: 3, type: 'adults', attributes: {name: 'Harry Bovik'}}, 359 | ], 360 | }); 361 | 362 | await Office.jsonApi().index(); 363 | 364 | assertState(store, { 365 | people: { 366 | 1: {$id: '1', id: 1, type: 'Adult', name: 'Allen Newell', houses: [], offices: []}, 367 | 2: {$id: '2', id: 2, type: 'Adult', name: 'Herb Simon', houses: [], offices: []}, 368 | 3: {$id: '3', id: 3, type: 'Adult', name: 'Harry Bovik', houses: [], offices: []}, 369 | }, 370 | offices: { 371 | 1: {$id: '1', id: 1, people: [], name: 'Newell Simon Hall'}, 372 | 2: {$id: '2', id: 2, people: [], name: 'Wean Hall'}, 373 | }, 374 | inhabitables: { 375 | '1_1_offices': { 376 | $id: '1_1_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 1, 377 | inhabitable: null, person: null, 378 | }, 379 | '1_2_offices': { 380 | $id: '1_2_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 2, 381 | inhabitable: null, person: null, 382 | }, 383 | '1_3_offices': { 384 | $id: '1_3_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 3, 385 | inhabitable: null, person: null, 386 | }, 387 | '2_3_offices': { 388 | $id: '2_3_offices', id: null, inhabitable_id: 2, inhabitable_type: 'offices', person_id: 3, 389 | inhabitable: null, person: null, 390 | }, 391 | }, 392 | }); 393 | }); 394 | 395 | it('transforms the `morphedByMany` relation', async () => { 396 | const store = createStore(ModelFactory.presets); 397 | const {adults: Adult} = store.$db().models(); 398 | 399 | mock.onGet('/api/adults').reply(200, { 400 | data: [ 401 | { 402 | id: 1, 403 | type: 'adults', 404 | attributes: { 405 | name: 'Allen Newell', 406 | }, 407 | relationships: { 408 | houses: { 409 | data: [ 410 | {id: 1, type: 'houses'}, 411 | ], 412 | }, 413 | offices: { 414 | data: [ 415 | {id: 1, type: 'offices'}, 416 | ], 417 | }, 418 | }, 419 | }, 420 | { 421 | id: 2, 422 | type: 'adults', 423 | attributes: { 424 | name: 'Herb Simon', 425 | }, 426 | relationships: { 427 | houses: { 428 | data: [ 429 | {id: 2, type: 'houses'}, 430 | ], 431 | }, 432 | offices: { 433 | data: [ 434 | {id: 1, type: 'offices'}, 435 | ], 436 | }, 437 | }, 438 | }, 439 | ], 440 | included: [ 441 | {id: 1, type: 'houses', attributes: {name: 'Allen\'s House'}}, 442 | {id: 2, type: 'houses', attributes: {name: 'Herb\'s House'}}, 443 | {id: 1, type: 'offices', attributes: {name: 'Newell Simon Hall'}}, 444 | ], 445 | }); 446 | 447 | await Adult.jsonApi().index(); 448 | 449 | assertState(store, { 450 | people: { 451 | 1: {$id: '1', id: 1, type: 'Adult', houses: [], offices: [], name: 'Allen Newell'}, 452 | 2: {$id: '2', id: 2, type: 'Adult', houses: [], offices: [], name: 'Herb Simon'}, 453 | }, 454 | houses: { 455 | 1: {$id: '1', id: 1, people: [], name: 'Allen\'s House'}, 456 | 2: {$id: '2', id: 2, people: [], name: 'Herb\'s House'}, 457 | }, 458 | offices: { 459 | 1: {$id: '1', id: 1, people: [], name: 'Newell Simon Hall'}, 460 | }, 461 | inhabitables: { 462 | '1_1_houses': { 463 | $id: '1_1_houses', id: null, inhabitable_id: 1, inhabitable_type: 'houses', person_id: 1, 464 | inhabitable: null, person: null, 465 | }, 466 | '2_2_houses': { 467 | $id: '2_2_houses', id: null, inhabitable_id: 2, inhabitable_type: 'houses', person_id: 2, 468 | inhabitable: null, person: null, 469 | }, 470 | '1_1_offices': { 471 | $id: '1_1_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 1, 472 | inhabitable: null, person: null, 473 | }, 474 | '1_2_offices': { 475 | $id: '1_2_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 2, 476 | inhabitable: null, person: null, 477 | }, 478 | }, 479 | }); 480 | }); 481 | 482 | it('transforms the `morphTo` relation', async () => { 483 | const store = createStore(ModelFactory.presets); 484 | const {toys: Toy} = store.$db().models(); 485 | 486 | mock.onGet('/api/toys/1').reply(200, { 487 | data: { 488 | id: 1, 489 | type: 'toys', 490 | attributes: { 491 | name: 'Sheriff Woody', 492 | }, 493 | relationships: { 494 | owner: { 495 | data: {id: 1, type: 'children'}, 496 | }, 497 | }, 498 | }, 499 | included: [ 500 | {id: 1, type: 'children', attributes: {name: 'Andy'}}, 501 | ], 502 | }); 503 | 504 | await Toy.jsonApi().show(1); 505 | 506 | assertState(store, { 507 | toys: { 508 | 1: {$id: '1', id: 1, owner_id: 1, owner_type: 'children', name: 'Sheriff Woody', owner: null}, 509 | }, 510 | people: { 511 | '1': {$id: '1', id: 1, type: 'Child', name: 'Andy', toys: [], monster_in_the_closet: null}, 512 | }, 513 | }); 514 | }); 515 | 516 | it('transforms the `morphMany` relation', async () => { 517 | const store = createStore(ModelFactory.presets); 518 | const {children: Child} = store.$db().models(); 519 | 520 | mock.onGet('/api/children/1').reply(200, { 521 | data: { 522 | id: 1, 523 | type: 'children', 524 | attributes: { 525 | name: 'Andy', 526 | }, 527 | relationships: { 528 | toys: { 529 | data: [ 530 | {id: 1, type: 'toys'}, 531 | {id: 2, type: 'toys'}, 532 | {id: 3, type: 'toys'}, 533 | ], 534 | }, 535 | }, 536 | }, 537 | included: [ 538 | {id: 1, type: 'toys', attributes: {name: 'Sheriff Woody'}}, 539 | {id: 2, type: 'toys', attributes: {name: 'Buzz Lightyear'}}, 540 | {id: 3, type: 'toys', attributes: {name: 'Bo Peep'}}, 541 | ], 542 | }); 543 | 544 | await Child.jsonApi().show(1); 545 | 546 | assertState(store, { 547 | people: { 548 | '1': {$id: '1', id: 1, type: 'Child', name: 'Andy', toys: [], monster_in_the_closet: null}, 549 | }, 550 | toys: { 551 | 1: {$id: '1', id: 1, owner_id: 1, owner_type: 'children', name: 'Sheriff Woody', owner: null}, 552 | 2: {$id: '2', id: 2, owner_id: 1, owner_type: 'children', name: 'Buzz Lightyear', owner: null}, 553 | 3: {$id: '3', id: 3, owner_id: 1, owner_type: 'children', name: 'Bo Peep', owner: null}, 554 | }, 555 | }); 556 | }); 557 | 558 | it('transforms the `morphOne` relation', async () => { 559 | const store = createStore(ModelFactory.presets); 560 | const {children: Child} = store.$db().models(); 561 | 562 | mock.onGet('/api/children/1').reply(200, { 563 | data: { 564 | id: 1, 565 | type: 'children', 566 | attributes: { 567 | name: 'Boo', 568 | }, 569 | relationships: { 570 | 'monster-in-the-closet': { 571 | data: {id: 1, type: 'monsters'}, 572 | }, 573 | }, 574 | }, 575 | included: [ 576 | {id: 1, type: 'monsters', attributes: {name: 'Sully'}}, 577 | ], 578 | }); 579 | 580 | await Child.jsonApi().show(1); 581 | 582 | assertState(store, { 583 | people: { 584 | '1': {$id: '1', id: 1, type: 'Child', name: 'Boo', toys: [], monster_in_the_closet: null}, 585 | }, 586 | monsters: { 587 | 1: {$id: '1', id: 1, scaree_id: 1, scaree_type: 'children', name: 'Sully', scaree: null}, 588 | }, 589 | }); 590 | }); 591 | }); 592 | -------------------------------------------------------------------------------- /spec/feature/Requests.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import {describe, it, beforeEach, afterEach, expect} from '@jest/globals'; 4 | import {createStore} from 'spec/support/spec_helper'; 5 | import ModelFactory from '../models/ModelFactory'; 6 | import Utils from '../../src/Utils'; 7 | 8 | describe('Feature - Requests', () => { 9 | let mock; 10 | 11 | beforeEach(() => { 12 | mock = new MockAdapter(axios); 13 | }); 14 | 15 | afterEach(() => { 16 | mock.reset(); 17 | }); 18 | 19 | // This isn't actually testing vuex-orm-json-api, but it's demonstrating how validation errors might be handled. 20 | it('throws an Axios request error with JSON:API error objects', async () => { 21 | const store = createStore(ModelFactory.presets); 22 | const {users: User} = store.$db().models(); 23 | 24 | const payload = { 25 | user: {}, 26 | }; 27 | 28 | const responseErrors = [ 29 | {id: 1, detail: 'Validation failed: Name can\'t be blank'}, 30 | ]; 31 | 32 | mock.onPost('/api/users', payload).reply(422, response); 33 | 34 | await expect(User.jsonApi().create(payload)).rejects.toThrow(new Error('Request failed with status code 422')); 35 | 36 | try { 37 | await User.jsonApi().create(payload); 38 | } catch (jsonApiError) { 39 | // This is the Axios error's status. 40 | expect(jsonApiError.cause.response.status).toBe(422); 41 | // This is the `JsonApiError`'s own response. 42 | expect(jsonApiError.response.errors).toEqual(responseErrors); 43 | } 44 | }); 45 | 46 | it('throws a `JsonApiError` when a model can\'t be found', async () => { 47 | const store = createStore(ModelFactory.presets); 48 | const {users: User} = store.$db().models(); 49 | 50 | mock.onGet('/api/users').reply(200, { 51 | data: { 52 | id: 1, 53 | type: 'things', 54 | }, 55 | }); 56 | 57 | await expect(User.jsonApi().index()).rejects.toThrow( 58 | Utils.error('Couldn\'t find the model for entity type `things`'), 59 | ); 60 | }); 61 | 62 | it('exposes top-level JSON:API properties like `meta` through `rawRequest`', async () => { 63 | const store = createStore(ModelFactory.presets); 64 | const {users: User} = store.$db().models(); 65 | 66 | mock.onGet('/api/users/1').reply(200, { 67 | data: { 68 | id: 1, 69 | type: 'users', 70 | }, 71 | meta: { 72 | count: 1, 73 | }, 74 | }); 75 | 76 | expect((await User.jsonApi().rawRequest({method: 'get', url: '/api/users/1'})).meta.count).toEqual(1); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /spec/feature/RestfulActions.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import {describe, it, beforeEach, afterEach, expect} from '@jest/globals'; 4 | import {createStore, assertState} from 'spec/support/spec_helper'; 5 | import ModelFactory from '../models/ModelFactory'; 6 | import Utils from '../../src/Utils'; 7 | 8 | describe('Feature - RESTful Actions', () => { 9 | let mock; 10 | 11 | beforeEach(() => { 12 | mock = new MockAdapter(axios); 13 | }); 14 | 15 | afterEach(() => { 16 | mock.reset(); 17 | }); 18 | 19 | it('runs the `index` action', async () => { 20 | const store = createStore(ModelFactory.presets); 21 | const {users: User} = store.$db().models(); 22 | 23 | mock.onGet('/api/users').reply(200, { 24 | data: [ 25 | {id: 1, type: 'users', attributes: {name: 'Harry Bovik'}}, 26 | {id: 2, type: 'users', attributes: {name: 'Allen Newell'}}, 27 | {id: 3, type: 'users', attributes: {name: 'Herb Simon'}}, 28 | ], 29 | }); 30 | 31 | const users = await User.jsonApi().index(); 32 | 33 | expect(users).toEqual([ 34 | { 35 | $id: '1', 36 | id: 1, 37 | name: 'Harry Bovik', 38 | users_groups: [], 39 | groups: [], 40 | user_profile: null, 41 | user_profile_attributes: [], 42 | embedded_group_ids: null, 43 | embedded_groups: [], 44 | }, 45 | { 46 | $id: '2', 47 | id: 2, 48 | name: 'Allen Newell', 49 | users_groups: [], 50 | groups: [], 51 | user_profile: null, 52 | user_profile_attributes: [], 53 | embedded_group_ids: null, 54 | embedded_groups: [], 55 | }, 56 | { 57 | $id: '3', 58 | id: 3, 59 | name: 'Herb Simon', 60 | users_groups: [], 61 | groups: [], 62 | user_profile: null, 63 | user_profile_attributes: [], 64 | embedded_group_ids: null, 65 | embedded_groups: [], 66 | }, 67 | ]); 68 | 69 | assertState(store, { 70 | users: { 71 | 1: { 72 | $id: '1', 73 | id: 1, 74 | name: 'Harry Bovik', 75 | users_groups: [], 76 | groups: [], 77 | user_profile: null, 78 | user_profile_attributes: [], 79 | embedded_group_ids: null, 80 | embedded_groups: [], 81 | }, 82 | 2: { 83 | $id: '2', 84 | id: 2, 85 | name: 'Allen Newell', 86 | users_groups: [], 87 | groups: [], 88 | user_profile: null, 89 | user_profile_attributes: [], 90 | embedded_group_ids: null, 91 | embedded_groups: [], 92 | }, 93 | 3: { 94 | $id: '3', 95 | id: 3, 96 | name: 'Herb Simon', 97 | users_groups: [], 98 | groups: [], 99 | user_profile: null, 100 | user_profile_attributes: [], 101 | embedded_group_ids: null, 102 | embedded_groups: [], 103 | }, 104 | }, 105 | groups: {}, 106 | users_groups: {}, 107 | user_profiles: {}, 108 | user_profile_attributes: {}, 109 | }); 110 | }); 111 | 112 | it('runs the `show` action', async () => { 113 | const store = createStore(ModelFactory.presets); 114 | const {users: User} = store.$db().models(); 115 | 116 | mock.onGet('/api/users/1').reply(200, { 117 | data: {id: 1, type: 'users', attributes: {name: 'Harry Bovik'}}, 118 | }); 119 | 120 | const user = await User.jsonApi().show(1); 121 | 122 | expect(user).toEqual({ 123 | $id: '1', 124 | id: 1, 125 | name: 'Harry Bovik', 126 | users_groups: [], 127 | groups: [], 128 | user_profile: null, 129 | user_profile_attributes: [], 130 | embedded_group_ids: null, 131 | embedded_groups: [], 132 | }); 133 | 134 | assertState(store, { 135 | users: { 136 | 1: { 137 | $id: '1', 138 | id: 1, 139 | name: 'Harry Bovik', 140 | users_groups: [], 141 | groups: [], 142 | user_profile: null, 143 | user_profile_attributes: [], 144 | embedded_group_ids: null, 145 | embedded_groups: [], 146 | }, 147 | }, 148 | groups: {}, 149 | users_groups: {}, 150 | user_profiles: {}, 151 | user_profile_attributes: {}, 152 | }); 153 | }); 154 | 155 | it('runs the `create` action', async () => { 156 | const store = createStore(ModelFactory.presets); 157 | const {users: User} = store.$db().models(); 158 | 159 | const payload = { 160 | user: { 161 | name: 'Harry Bovik', 162 | }, 163 | }; 164 | 165 | mock.onPost('/api/users', payload).reply(200, { 166 | data: { 167 | id: 1, type: 'users', attributes: {name: 'Harry Bovik'}, 168 | }, 169 | }); 170 | 171 | const user = await User.jsonApi().create(payload); 172 | 173 | expect(user).toEqual({ 174 | $id: '1', 175 | id: 1, 176 | name: 'Harry Bovik', 177 | users_groups: [], 178 | groups: [], 179 | user_profile: null, 180 | user_profile_attributes: [], 181 | embedded_group_ids: null, 182 | embedded_groups: [], 183 | }); 184 | 185 | assertState(store, { 186 | users: { 187 | 1: { 188 | $id: '1', 189 | id: 1, 190 | name: 'Harry Bovik', 191 | users_groups: [], 192 | groups: [], 193 | user_profile: null, 194 | user_profile_attributes: [], 195 | embedded_group_ids: null, 196 | embedded_groups: [], 197 | }, 198 | }, 199 | groups: {}, 200 | users_groups: {}, 201 | user_profiles: {}, 202 | user_profile_attributes: {}, 203 | }); 204 | }); 205 | 206 | it('runs the `update` action', async () => { 207 | const store = createStore(ModelFactory.presets); 208 | const {users: User} = store.$db().models(); 209 | 210 | User.insertOrUpdate({ 211 | data: { 212 | id: 1, 213 | name: 'Harry Bovik', 214 | }, 215 | }); 216 | 217 | const payload = { 218 | user: { 219 | name: 'Harry Q. Bovik', 220 | }, 221 | }; 222 | 223 | mock.onPatch('/api/users/1', payload).reply(200, { 224 | data: { 225 | id: 1, type: 'users', attributes: {name: 'Harry Q. Bovik'}, 226 | }, 227 | }); 228 | 229 | const user = await User.jsonApi().update(1, payload); 230 | 231 | expect(user).toEqual({ 232 | $id: '1', 233 | id: 1, 234 | name: 'Harry Q. Bovik', 235 | users_groups: [], 236 | groups: [], 237 | user_profile: null, 238 | user_profile_attributes: [], 239 | embedded_group_ids: null, 240 | embedded_groups: [], 241 | }); 242 | 243 | assertState(store, { 244 | users: { 245 | 1: { 246 | $id: '1', 247 | id: 1, 248 | name: 'Harry Q. Bovik', 249 | users_groups: [], 250 | groups: [], 251 | user_profile: null, 252 | user_profile_attributes: [], 253 | embedded_group_ids: null, 254 | embedded_groups: [], 255 | }, 256 | }, 257 | groups: {}, 258 | users_groups: {}, 259 | user_profiles: {}, 260 | user_profile_attributes: {}, 261 | }); 262 | }); 263 | 264 | it('runs the `destroy` action', async () => { 265 | const store = createStore(ModelFactory.presets); 266 | const {users: User} = store.$db().models(); 267 | 268 | User.insertOrUpdate({ 269 | data: { 270 | id: 1, 271 | name: 'Harry Bovik', 272 | }, 273 | }); 274 | 275 | mock.onDelete('/api/users/1').reply(204); 276 | 277 | expect(await User.jsonApi().destroy(1)).toBe(null); 278 | 279 | assertState(store, { 280 | users: {}, 281 | groups: {}, 282 | users_groups: {}, 283 | user_profiles: {}, 284 | user_profile_attributes: {}, 285 | }); 286 | 287 | User.insertOrUpdate({ 288 | data: { 289 | id: 1, 290 | name: 'Harry Bovik', 291 | }, 292 | }); 293 | 294 | mock.onDelete('/api/users/1').reply(200, {}); 295 | 296 | expect(await User.jsonApi().destroy(1)).toBe(null); 297 | 298 | assertState(store, { 299 | users: {}, 300 | groups: {}, 301 | users_groups: {}, 302 | user_profiles: {}, 303 | user_profile_attributes: {}, 304 | }); 305 | }); 306 | 307 | it('throws an error when expected multiplicity is one', async () => { 308 | const store = createStore(ModelFactory.presets); 309 | const {users: User} = store.$db().models(); 310 | 311 | mock.onGet('/api/users/1').reply(200, { 312 | data: [ 313 | {id: 1, type: 'users', attributes: {name: 'Harry Bovik'}}, 314 | {id: 2, type: 'users', attributes: {name: 'Allen Newell'}}, 315 | {id: 3, type: 'users', attributes: {name: 'Herb Simon'}}, 316 | ], 317 | }); 318 | 319 | await expect(User.jsonApi().show(1)).rejects.toThrow( 320 | Utils.error('Expected an object JSON:API response, but got an array or nothing instead'), 321 | ); 322 | }); 323 | 324 | it('throws an error when expected multiplicity is many', async () => { 325 | const store = createStore(ModelFactory.presets); 326 | const {users: User} = store.$db().models(); 327 | 328 | mock.onGet('/api/users').reply(200, { 329 | data: { 330 | id: 1, type: 'users', attributes: {name: 'Harry Bovik'}, 331 | }, 332 | }); 333 | 334 | await expect(User.jsonApi().index()).rejects.toThrow( 335 | Utils.error('Expected an array JSON:API response, but got an object or nothing instead'), 336 | ); 337 | }); 338 | 339 | it('applies a user-defined query scope', async () => { 340 | const store = createStore(ModelFactory.presets); 341 | const {users: User} = store.$db().models(); 342 | 343 | mock.onGet('/api/users/1').reply(200, { 344 | data: { 345 | id: 1, 346 | type: 'users', 347 | attributes: { 348 | name: 'Harry Bovik', 349 | }, 350 | relationships: { 351 | 'users-groups': { 352 | data: [ 353 | {id: 1, type: 'users-groups'}, 354 | ], 355 | }, 356 | }, 357 | }, 358 | included: [ 359 | { 360 | id: 1, 361 | type: 'users-groups', 362 | relationships: { 363 | group: { 364 | data: { 365 | id: 1, 366 | type: 'groups', 367 | }, 368 | }, 369 | }, 370 | }, 371 | {id: 1, type: 'groups', attributes: {name: 'CMU'}}, 372 | ], 373 | }); 374 | 375 | const user = await User.jsonApi().show(1, {scope: (query) => query.with('users_groups.group')}); 376 | 377 | expect(user).toEqual({ 378 | $id: '1', 379 | id: 1, 380 | name: 'Harry Bovik', 381 | users_groups: [ 382 | { 383 | $id: '[1,1]', 384 | id: 1, 385 | user_id: 1, 386 | user: null, 387 | group_id: 1, 388 | group: { 389 | $id: '1', 390 | id: 1, 391 | name: 'CMU', 392 | users: [], 393 | users_groups: [], 394 | }, 395 | }, 396 | ], 397 | groups: [], 398 | user_profile: null, 399 | user_profile_attributes: [], 400 | embedded_group_ids: null, 401 | embedded_groups: [], 402 | }); 403 | }); 404 | }); 405 | -------------------------------------------------------------------------------- /spec/feature/StiRelations.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import {describe, it, beforeEach, afterEach, expect} from '@jest/globals'; 4 | import {createStore, assertState} from 'spec/support/spec_helper'; 5 | import ModelFactory from '../models/ModelFactory'; 6 | import Utils from '../../src/Utils'; 7 | 8 | describe('Feature - STI Relations', () => { 9 | let mock; 10 | 11 | beforeEach(() => { 12 | mock = new MockAdapter(axios); 13 | }); 14 | 15 | afterEach(() => { 16 | mock.reset(); 17 | }); 18 | 19 | it('refuses to write an object with incompatible type to the relation', async () => { 20 | const store = createStore(ModelFactory.presets); 21 | const {meetings: Meeting} = store.$db().models(); 22 | 23 | mock.onGet('/api/meetings/1').reply(200, { 24 | data: { 25 | id: 1, 26 | type: 'meetings', 27 | relationships: { 28 | chairperson: { 29 | data: {id: 1, type: 'toys'}, 30 | }, 31 | }, 32 | }, 33 | included: [ 34 | {id: 1, type: 'toys', attributes: {name: 'Slinky'}}, 35 | ], 36 | }); 37 | 38 | await expect(Meeting.jsonApi().show(1)).rejects.toThrow( 39 | Utils.error('Expected type `adults` but got `toys` in relation'), 40 | ); 41 | }); 42 | 43 | it('writes an object with type that is a superclass or subclass of the relation type', async () => { 44 | const store = createStore(ModelFactory.presets); 45 | const {meetings: Meeting} = store.$db().models(); 46 | 47 | mock.onGet('/api/meetings/1').reply(200, { 48 | data: { 49 | id: 1, 50 | type: 'meetings', 51 | relationships: { 52 | chairperson: { 53 | data: {id: 1, type: 'people'}, 54 | }, 55 | participants: { 56 | data: [ 57 | {id: 1, type: 'people'}, 58 | {id: 2, type: 'people'}, 59 | {id: 3, type: 'children'}, 60 | ], 61 | }, 62 | }, 63 | }, 64 | included: [ 65 | {id: 1, type: 'people', attributes: {type: 'Adult', name: 'Alice'}}, 66 | {id: 2, type: 'people', attributes: {type: null, name: 'Bob'}}, 67 | {id: 3, type: 'children', attributes: {name: 'Harry'}}, 68 | ], 69 | }); 70 | 71 | await Meeting.jsonApi().show(1); 72 | 73 | assertState(store, { 74 | meetings: { 75 | 1: {$id: '1', id: 1, chairperson: null, participant_ids: [1, 2, 3], participants: []}, 76 | }, 77 | people: { 78 | 1: {$id: '1', id: 1, type: 'Adult', name: 'Alice', offices: [], houses: []}, 79 | 2: {$id: '2', id: 2, type: null, name: 'Bob'}, 80 | 3: {$id: '3', id: 3, type: 'Child', name: 'Harry', toys: [], monster_in_the_closet: null}, 81 | }, 82 | }); 83 | }); 84 | 85 | it('preserves STI subtypes when pivot models are involved', async () => { 86 | const store = createStore(ModelFactory.presets); 87 | const {offices: Office} = store.$db().models(); 88 | 89 | mock.onGet('/api/offices').reply(200, { 90 | data: [ 91 | { 92 | id: 1, 93 | type: 'offices', 94 | attributes: { 95 | name: 'Newell Simon Hall', 96 | }, 97 | relationships: { 98 | people: { 99 | data: [ 100 | {id: 1, type: 'adults'}, 101 | ], 102 | }, 103 | }, 104 | }, 105 | ], 106 | included: [ 107 | {id: 1, type: 'adults', attributes: {name: 'Allen Newell'}}, 108 | ], 109 | }); 110 | 111 | await Office.jsonApi().index(); 112 | 113 | assertState(store, { 114 | people: { 115 | 1: {$id: '1', id: 1, type: 'Adult', name: 'Allen Newell', houses: [], offices: []}, 116 | }, 117 | offices: { 118 | 1: {$id: '1', id: 1, people: [], name: 'Newell Simon Hall'}, 119 | }, 120 | inhabitables: { 121 | '1_1_offices': { 122 | $id: '1_1_offices', id: null, inhabitable_id: 1, inhabitable_type: 'offices', person_id: 1, 123 | inhabitable: null, person: null, 124 | }, 125 | }, 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /spec/feature/VuexOrmJsonApi.js: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from '@jest/globals'; 2 | import {createStore} from 'spec/support/spec_helper'; 3 | import ModelFactory from '../models/ModelFactory'; 4 | 5 | describe('Feature - Vuex ORM JSON:API', () => { 6 | it('mixes the adapter\'s configuration into models', () => { 7 | let store = createStore(ModelFactory.presets); 8 | let {users: User} = store.$db().models(); 9 | 10 | expect(User).toHaveProperty('setAxios'); 11 | expect(User).toHaveProperty('globalJsonApiConfig'); 12 | expect(User).toHaveProperty('jsonApiConfig'); 13 | expect(User).toHaveProperty('jsonApi'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /spec/models/AdultsInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({adults, people, inhabitables, houses, offices}) { 3 | Object.assign(adults, { 4 | entity: 'adults', 5 | baseEntity: 'people', 6 | 7 | fields() { 8 | return { 9 | ...people.fields(), 10 | 11 | houses: this.morphedByMany(houses, inhabitables, 'person_id', 'inhabitable_id', 'inhabitable_type'), 12 | 13 | offices: this.morphedByMany(offices, inhabitables, 'person_id', 'inhabitable_id', 'inhabitable_type'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/ChildrenInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({children, people, toys, monsters}) { 3 | Object.assign(children, { 4 | entity: 'children', 5 | baseEntity: 'people', 6 | 7 | fields() { 8 | return { 9 | ...people.fields(), 10 | 11 | toys: this.morphMany(toys, 'owner_id', 'owner_type'), 12 | 13 | monster_in_the_closet: this.morphOne(monsters, 'scaree_id', 'scaree_type'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/GroupsInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({groups, users_groups, users}) { 3 | Object.assign(groups, { 4 | entity: 'groups', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | users_groups: this.hasMany(users_groups, 'group_id'), 12 | 13 | users: this.belongsToMany(users, users_groups, 'group_id', 'user_id'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/HousesInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({houses, people, inhabitables}) { 3 | Object.assign(houses, { 4 | entity: 'houses', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | people: this.morphToMany(people, inhabitables, 'person_id', 'inhabitable_id', 'inhabitable_type'), 12 | }; 13 | }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/models/InhabitablesInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({inhabitables, people}) { 3 | Object.assign(inhabitables, { 4 | entity: 'inhabitables', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | 10 | person_id: this.attr(null), 11 | person: this.belongsTo(people, 'person_id'), 12 | 13 | inhabitable_id: this.attr(null), 14 | inhabitable_type: this.attr(null), 15 | inhabitable: this.morphTo('inhabitable_id', 'inhabitable_type'), 16 | }; 17 | }, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /spec/models/MeetingsInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({meetings, adults, people}) { 3 | Object.assign(meetings, { 4 | entity: 'meetings', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | 10 | chairperson: this.belongsTo(adults, 'chairperson_id'), 11 | 12 | participant_ids: this.attr(null), 13 | participants: this.hasManyBy(people, 'participant_ids'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/ModelFactory.js: -------------------------------------------------------------------------------- 1 | import {Database, Model} from '@vuex-orm/core'; 2 | import {topologicalSort} from '../support/topological_sort'; 3 | import groups_initializer from './GroupsInitializer'; 4 | import users_initializer from './UsersInitializer'; 5 | import users_groups_initializer from './UsersGroupsInitializer'; 6 | import user_profiles_initializer from './UserProfilesInitializer'; 7 | import user_profile_attributes_initializer from './UserProfileAttributesInitializer'; 8 | import people_initializer from './PeopleInitializer'; 9 | import inhabitables_initializer from './InhabitablesInitializer'; 10 | import houses_initializer from './HousesInitializer'; 11 | import offices_initializer from './OfficesInitializer'; 12 | import adults_initializer from './AdultsInitializer'; 13 | import children_initializer from './ChildrenInitializer'; 14 | import toys_initializer from './ToysInitializer'; 15 | import monsters_initializer from './MonstersInitializer'; 16 | import meetings_initializer from './MeetingsInitializer'; 17 | 18 | export default class { 19 | static initializers = { 20 | groups_initializer, 21 | users_initializer, 22 | users_groups_initializer, 23 | user_profiles_initializer, 24 | user_profile_attributes_initializer, 25 | people_initializer, 26 | inhabitables_initializer, 27 | houses_initializer, 28 | offices_initializer, 29 | adults_initializer, 30 | children_initializer, 31 | toys_initializer, 32 | monsters_initializer, 33 | meetings_initializer, 34 | }; 35 | 36 | static presets = { 37 | users: null, 38 | groups: null, 39 | users_groups: null, 40 | user_profiles: null, 41 | user_profile_attributes: null, 42 | people: null, 43 | inhabitables: null, 44 | houses: null, 45 | offices: null, 46 | adults: 'people', 47 | children: 'people', 48 | toys: null, 49 | monsters: null, 50 | meetings: null, 51 | } 52 | 53 | static createDatabase(entitiesToBaseEntities) { 54 | let database = new Database(); 55 | 56 | let entitiesToDeps = Object.fromEntries( 57 | Object.entries(entitiesToBaseEntities).map(([entity, baseEntity]) => { 58 | if (!baseEntity) { 59 | return [entity, []]; 60 | } else { 61 | return [entity, [baseEntity]]; 62 | } 63 | }), 64 | ); 65 | 66 | let entitiesToModels = {}; 67 | let sortedEntities = topologicalSort(entitiesToDeps).reverse(); 68 | 69 | // Iterate over the list of entities sorted from standalone models to inherited ones. 70 | sortedEntities.forEach((entity) => { 71 | let deps = entitiesToDeps[entity]; 72 | 73 | let model = deps.length === 0 ? class extends Model {} : class extends entitiesToModels[deps[0]] {}; 74 | entitiesToModels[entity] = model; 75 | }); 76 | 77 | sortedEntities.forEach((entity) => { 78 | let model = entitiesToModels[entity]; 79 | 80 | this.initializers[`${entity}_initializer`].initialize(entitiesToModels); 81 | database.register(model); 82 | }); 83 | 84 | return database; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /spec/models/MonstersInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({monsters}) { 3 | Object.assign(monsters, { 4 | entity: 'monsters', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | scaree_id: this.attr(null), 12 | scaree_type: this.attr(null), 13 | scaree: this.morphTo('scaree_id', 'scaree_type'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/OfficesInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({offices, people, inhabitables}) { 3 | Object.assign(offices, { 4 | entity: 'offices', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | people: this.morphToMany(people, inhabitables, 'person_id', 'inhabitable_id', 'inhabitable_type'), 12 | }; 13 | }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/models/PeopleInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({people, adults, children}) { 3 | Object.assign(people, { 4 | entity: 'people', 5 | 6 | types() { 7 | return { 8 | Adult: adults, 9 | Child: children, 10 | }; 11 | }, 12 | 13 | fields() { 14 | return { 15 | id: this.attr(null), 16 | 17 | // The STI discriminator field. 18 | type: this.attr(null), 19 | 20 | name: this.attr(null), 21 | }; 22 | }, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /spec/models/ToysInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({toys}) { 3 | Object.assign(toys, { 4 | entity: 'toys', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | owner_id: this.attr(null), 12 | owner_type: this.attr(null), 13 | owner: this.morphTo('owner_id', 'owner_type'), 14 | }; 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/models/UserProfileAttributesInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({user_profile_attributes, user_profiles}) { 3 | Object.assign(user_profile_attributes, { 4 | entity: 'user_profile_attributes', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | 10 | user_profile_id: this.attr(null), 11 | user_profile: this.belongsTo(user_profiles, 'user_profile_id'), 12 | }; 13 | }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/models/UserProfilesInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({user_profiles, users}) { 3 | Object.assign(user_profiles, { 4 | entity: 'user_profiles', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | 10 | user_id: this.attr(null), 11 | user: this.belongsTo(users, 'user_id'), 12 | }; 13 | }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/models/UsersGroupsInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({users_groups, users, groups}) { 3 | Object.assign(users_groups, { 4 | entity: 'users_groups', 5 | 6 | primaryKey: ['user_id', 'group_id'], 7 | 8 | fields() { 9 | return { 10 | id: this.attr(null), 11 | 12 | user_id: this.attr(null), 13 | user: this.belongsTo(users, 'user_id'), 14 | 15 | group_id: this.attr(null), 16 | group: this.belongsTo(groups, 'group_id'), 17 | }; 18 | }, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /spec/models/UsersInitializer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static initialize({users, users_groups, groups, user_profiles, user_profile_attributes}) { 3 | Object.assign(users, { 4 | entity: 'users', 5 | 6 | fields() { 7 | return { 8 | id: this.attr(null), 9 | name: this.attr(null), 10 | 11 | users_groups: this.hasMany(users_groups, 'user_id'), 12 | 13 | groups: this.belongsToMany(groups, users_groups, 'user_id', 'group_id'), 14 | 15 | user_profile: this.hasOne(user_profiles, 'user_id'), 16 | 17 | user_profile_attributes: this.hasManyThrough( 18 | user_profile_attributes, user_profiles, 'user_id', 'user_profile_id', 19 | ), 20 | 21 | embedded_group_ids: this.attr(null), 22 | 23 | embedded_groups: this.hasManyBy(groups, 'embedded_group_ids'), 24 | }; 25 | }, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /spec/support/spec_helper.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Vue from 'vue'; 3 | import Vuex, {Store} from 'vuex'; 4 | import VuexORM from '@vuex-orm/core'; 5 | import VuexOrmJsonApi, {RestfulActionsMixin} from '@/index'; 6 | import {expect} from '@jest/globals'; 7 | import ModelFactory from '../models/ModelFactory'; 8 | 9 | Vue.use(Vuex); 10 | 11 | VuexORM.use(VuexOrmJsonApi, {axios, mixins: [RestfulActionsMixin]}); 12 | 13 | export function createStore(entitiesToBaseEntities) { 14 | return new Store({ 15 | plugins: [VuexORM.install(ModelFactory.createDatabase(entitiesToBaseEntities))], 16 | strict: true, 17 | }); 18 | } 19 | 20 | export function createState(entitiesToData) { 21 | return { 22 | $name: 'entities', 23 | 24 | ...Object.keys(entitiesToData).reduce((carry, name) => { 25 | const data = entitiesToData[name]; 26 | 27 | carry[name] = { 28 | $connection: 'entities', 29 | $name: name, 30 | data, 31 | }; 32 | 33 | return carry; 34 | }, {}), 35 | }; 36 | } 37 | 38 | export function assertState(store, entitiesToData) { 39 | let missingEntitiesToData = Object.fromEntries( 40 | Object. 41 | keys(store.state.entities). 42 | filter(entity => !Object.prototype.hasOwnProperty.call(entitiesToData, entity)).map(entity => [entity, {}]), 43 | ); 44 | 45 | // Excise the special `$name` property. 46 | let {['$name']: _, ...remaining} = missingEntitiesToData; 47 | missingEntitiesToData = remaining; 48 | 49 | expect(store.state.entities).toEqual(createState({...entitiesToData, ...missingEntitiesToData})); 50 | } 51 | -------------------------------------------------------------------------------- /spec/support/topological_sort.js: -------------------------------------------------------------------------------- 1 | (function (exports) { 2 | 'use strict'; 3 | var topologicalSort = (function () { 4 | function topologicalSortHelper(node, visited, temp, graph, result) { 5 | temp[node] = true; 6 | var neighbors = graph[node]; 7 | for (var i = 0; i < neighbors.length; i += 1) { 8 | var n = neighbors[i]; 9 | if (temp[n]) { 10 | throw new Error('The graph is not a DAG'); 11 | } 12 | if (!visited[n]) { 13 | topologicalSortHelper(n, visited, temp, graph, result); 14 | } 15 | } 16 | temp[node] = false; 17 | visited[node] = true; 18 | result.push(node); 19 | } 20 | /** 21 | * Topological sort algorithm of a directed acyclic graph.

22 | * Time complexity: O(|E| + |V|) where E is a number of edges 23 | * and |V| is the number of nodes. 24 | * 25 | * @public 26 | * @module graphs/others/topological-sort 27 | * @param {Array} graph Adjacency list, which represents the graph. 28 | * @returns {Array} Ordered vertices. 29 | * 30 | * @example 31 | * var topsort = 32 | * require('path-to-algorithms/src/graphs/' + 33 | * 'others/topological-sort').topologicalSort; 34 | * var graph = { 35 | * v1: ['v2', 'v5'], 36 | * v2: [], 37 | * v3: ['v1', 'v2', 'v4', 'v5'], 38 | * v4: [], 39 | * v5: [] 40 | * }; 41 | * var vertices = topsort(graph); // ['v3', 'v4', 'v1', 'v5', 'v2'] 42 | */ 43 | return function (graph) { 44 | var result = []; 45 | var visited = []; 46 | var temp = []; 47 | for (var node in graph) { 48 | if (!visited[node] && !temp[node]) { 49 | topologicalSortHelper(node, visited, temp, graph, result); 50 | } 51 | } 52 | return result.reverse(); 53 | }; 54 | }()); 55 | exports.topologicalSort = topologicalSort; 56 | }(typeof exports === 'undefined' ? window : exports)); 57 | -------------------------------------------------------------------------------- /src/InsertionStore.js: -------------------------------------------------------------------------------- 1 | import Utils from './Utils'; 2 | 3 | /** 4 | * A holding area for pre-insertion Vuex ORM records that are meant to be shared and manipulated relationally across 5 | * multiple resources in the JSON:API document. 6 | */ 7 | export default class { 8 | constructor() { 9 | this.typesToIdsToRecords = {}; 10 | } 11 | 12 | /* 13 | * Attempts to fetch the record of the given type and id. If it doesn't exist, creates, inserts, and returns a new 14 | * record. 15 | */ 16 | fetchRecord(type, id, localKey = null) { 17 | let records = this.typesToIdsToRecords[type]; 18 | 19 | if (!records) { 20 | records = {}; 21 | this.typesToIdsToRecords[type] = records; 22 | } 23 | 24 | let record = records[id]; 25 | 26 | if (!record) { 27 | record = {[localKey ? localKey : 'id']: id}; 28 | records[id] = record; 29 | } 30 | 31 | return record; 32 | } 33 | 34 | /** 35 | * Iterates over each type and calls the given function, passing in the type and the associated ids-to-records hash. 36 | */ 37 | forEachType(fn) { 38 | for (const [type, idsToRecords] of Object.entries(this.typesToIdsToRecords)) { 39 | fn(type, idsToRecords); 40 | } 41 | } 42 | 43 | /** 44 | * Finds a record of the given type and id. 45 | */ 46 | findRecord(type, id) { 47 | let idsToRecords = this.typesToIdsToRecords[type]; 48 | let record = idsToRecords && idsToRecords[id]; 49 | 50 | if (!record) { 51 | throw Utils.error(`Record with (type, id) = (\`${type}\`, \`${id}\`) not found`); 52 | } 53 | 54 | return record; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/JsonApiError.js: -------------------------------------------------------------------------------- 1 | export default class extends Error { 2 | /** 3 | * Creates a new library error. 4 | */ 5 | constructor(axiosError, jsonApiResponse) { 6 | super(axiosError.message, {cause: axiosError}); 7 | 8 | this.response = jsonApiResponse; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Utils.js: -------------------------------------------------------------------------------- 1 | import {PROJECT_NAME_HUMANIZED} from './constants'; 2 | import JsonApiError from './JsonApiError'; 3 | 4 | export default class { 5 | static error(message) { 6 | return new JsonApiError(`[${PROJECT_NAME_HUMANIZED}] ${message}`); 7 | } 8 | 9 | static modelFor(database, type) { 10 | const model = database.models()[type]; 11 | 12 | if (!model) { 13 | throw this.error(`Couldn't find the model for entity type \`${type}\``); 14 | } 15 | 16 | return model; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/VuexOrmJsonApi.js: -------------------------------------------------------------------------------- 1 | import ModelMixin from './mixins/ModelMixin'; 2 | 3 | export default class { 4 | constructor(components, config) { 5 | this.modelComponent = components.Model; 6 | 7 | let { 8 | HasOne, BelongsTo, HasMany, HasManyBy, HasManyThrough, BelongsToMany, MorphTo, MorphOne, MorphMany, MorphToMany, 9 | MorphedByMany, Attr, String, Number, Boolean, Uid, 10 | } = components; 11 | 12 | this.attributeClasses = { 13 | HasOne, BelongsTo, HasMany, HasManyBy, HasManyThrough, BelongsToMany, MorphTo, MorphOne, MorphMany, MorphToMany, 14 | MorphedByMany, Attr, String, Number, Boolean, Uid, 15 | }; 16 | 17 | this.config = config; 18 | } 19 | 20 | install() { 21 | ModelMixin.include(this.modelComponent, this.attributeClasses, this.config); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const PROJECT_NAME_HUMANIZED = 'Vuex ORM JSON:API'; 2 | 3 | export const API_ROOT = '/api'; 4 | 5 | const dashReplacementRegExp = new RegExp('-', 'g'); 6 | 7 | export const kebabCaseToSnakeCase = ((type) => type.replace(dashReplacementRegExp, '_')); 8 | 9 | export const defaultResourceToEntityCase = kebabCaseToSnakeCase; 10 | 11 | export const defaultEntityToResourceRouteCase = (type) => type; 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import VuexOrmJsonApi from './VuexOrmJsonApi'; 2 | 3 | export default class { 4 | static install(components, config) { 5 | (new VuexOrmJsonApi(components, config)).install(); 6 | } 7 | } 8 | 9 | export {default as RestfulActionsMixin} from './mixins/RestfulActionsMixin'; 10 | -------------------------------------------------------------------------------- /src/json_api/Request.js: -------------------------------------------------------------------------------- 1 | import Utils from '../Utils'; 2 | import Response from './Response'; 3 | import JsonApiError from '../JsonApiError'; 4 | 5 | export default class { 6 | /** 7 | * Creates a new request. 8 | */ 9 | constructor(model) { 10 | this.model = model; 11 | } 12 | 13 | /* 14 | * Gets the axios instance. 15 | */ 16 | get axios() { 17 | if (!this.model.axios) { 18 | return Utils.error('The axios instance is not registered. Please register the axios instance to the model.'); 19 | } 20 | 21 | return this.model.axios; 22 | } 23 | 24 | /** 25 | * Performs an HTTP `GET`. 26 | */ 27 | get(url, config = {}) { 28 | return this.request({ 29 | method: 'get', url, 30 | ...config, 31 | }); 32 | } 33 | 34 | /** 35 | * Performs an HTTP `POST`. 36 | */ 37 | post(url, data = {}, config = {}) { 38 | return this.request({ 39 | method: 'post', url, data, 40 | ...config, 41 | }); 42 | } 43 | 44 | /** 45 | * Performs an HTTP `PUT`. 46 | */ 47 | put(url, data = {}, config = {}) { 48 | return this.request({ 49 | method: 'put', url, data, 50 | ...config, 51 | }); 52 | } 53 | 54 | /** 55 | * Performs an HTTP `PATCH`. 56 | */ 57 | patch(url, data = {}, config = {}) { 58 | return this.request({ 59 | method: 'patch', url, data, 60 | ...config, 61 | }); 62 | } 63 | 64 | /** 65 | * Performs an HTTP `DELETE`. 66 | */ 67 | delete(url, config = {}) { 68 | return this.request({ 69 | method: 'delete', url, 70 | ...config, 71 | }); 72 | } 73 | 74 | /** 75 | * Performs an API request: Awaits an axios request, gets the axios response, and awaits the database commit. 76 | */ 77 | async request(config) { 78 | return await (await this.rawRequest(config)).commit(); 79 | } 80 | 81 | /** 82 | * Performs an API request: Awaits an axios request, gets the axios response, does not make a database commit, and 83 | * returns the original response object for inspection of stuff like JSON:API `meta`. 84 | */ 85 | async rawRequest(config) { 86 | const axiosConfig = { 87 | ...this.model.globalJsonApiConfig, 88 | ...this.model.jsonApiConfig, 89 | ...config, 90 | }; 91 | 92 | try { 93 | return new Response(this.model, await this.axios.request(axiosConfig), axiosConfig); 94 | } catch (axiosError) { 95 | throw new JsonApiError(axiosError, new Response(this.model, axiosError.response, axiosConfig)); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/json_api/Response.js: -------------------------------------------------------------------------------- 1 | import Utils from '../Utils'; 2 | import DocumentTransformer from '../transformers/DocumentTransformer'; 3 | 4 | export default class { 5 | /** 6 | * Create a new response instance. 7 | */ 8 | constructor(model, response, config) { 9 | this.model = model; 10 | this.response = response; 11 | this.config = config; 12 | this.documentTransformer = new DocumentTransformer(model.database(), config); 13 | 14 | let responseData = this.response.data; 15 | 16 | if (responseData) { 17 | ( 18 | { 19 | meta: this.meta, 20 | jsonapi: this.jsonapi, 21 | links: this.links, 22 | errors: this.errors, 23 | } = responseData 24 | ); 25 | } 26 | } 27 | 28 | /** 29 | * Commit response data to the store, potentially returning newly inserted records. 30 | */ 31 | async commit() { 32 | let database = this.model.database(); 33 | let responseData = this.response.data; 34 | 35 | let insertionStore = null; 36 | let primaryData = responseData?.data; 37 | 38 | if (primaryData) { 39 | insertionStore = this.documentTransformer.transform(responseData); 40 | } 41 | 42 | let multiplicity = this.config.multiplicity; 43 | 44 | if (!multiplicity) { 45 | if (primaryData instanceof Array) { 46 | multiplicity = 'many'; 47 | } else if (primaryData) { 48 | multiplicity = 'one'; 49 | } else { 50 | multiplicity = 'none'; 51 | } 52 | } 53 | 54 | let scope = this.config.scope; 55 | 56 | switch (multiplicity) { 57 | case 'many': 58 | if (!(primaryData instanceof Array) || !primaryData) { 59 | throw Utils.error('Expected an array JSON:API response, but got an object or nothing instead'); 60 | } 61 | 62 | return await this.commitResources(database, primaryData, insertionStore, scope); 63 | case 'one': 64 | if (primaryData instanceof Array || !primaryData) { 65 | throw Utils.error('Expected an object JSON:API response, but got an array or nothing instead'); 66 | } 67 | 68 | return await this.commitResource(database, primaryData, insertionStore, scope); 69 | case 'none': 70 | if (primaryData) { 71 | throw Utils.error('Expected nothing for the JSON:API response\'s primary data, but got something instead'); 72 | } 73 | 74 | if (this.config.id) { 75 | await this.deleteRecord(this.config.id); 76 | } 77 | 78 | return null; 79 | default: 80 | throw Utils.error('Control should never reach here'); 81 | } 82 | } 83 | 84 | /** 85 | * Finds the newly upserted record from the given resource. 86 | */ 87 | findRecordFromResource(database, jsonApiResource, insertionStore, scope) { 88 | let entity = this.documentTransformer.resourceToEntityCase(jsonApiResource.type); 89 | let record = insertionStore.findRecord(entity, jsonApiResource.id); 90 | 91 | let model = Utils.modelFor(database, entity); 92 | let primaryKey = model.primaryKey; 93 | let primaryKeyValue; 94 | 95 | if (!(primaryKey instanceof Array)) { 96 | primaryKeyValue = record[primaryKey]; 97 | } else { 98 | primaryKeyValue = primaryKey.map((keyComponent) => record[keyComponent]); 99 | } 100 | 101 | let query = model.query().whereId(primaryKeyValue); 102 | 103 | if (scope) { 104 | scope(query); 105 | } 106 | 107 | return query.first(); 108 | } 109 | 110 | /** 111 | * Commits multiple JSON:API resources. 112 | */ 113 | async commitResources(database, primaryData, insertionStore, scope) { 114 | const promises = Array(); 115 | 116 | insertionStore.forEachType((entity, idsToRecords) => 117 | promises.push(Utils.modelFor(database, entity).insertOrUpdate({data: Object.values(idsToRecords)})) 118 | ); 119 | 120 | await Promise.all(promises); 121 | 122 | return primaryData.map((data) => this.findRecordFromResource(database, data, insertionStore, scope)); 123 | } 124 | 125 | /** 126 | * Commits a JSON:API resource. 127 | */ 128 | async commitResource(database, primaryData, insertionStore, scope) { 129 | const promises = Array(); 130 | 131 | insertionStore.forEachType((entity, idsToRecords) => 132 | promises.push(Utils.modelFor(database, entity).insertOrUpdate({data: Object.values(idsToRecords)})) 133 | ); 134 | 135 | await Promise.all(promises); 136 | 137 | return this.findRecordFromResource(database, primaryData, insertionStore, scope); 138 | } 139 | 140 | /** 141 | * Deletes a record. 142 | */ 143 | async deleteRecord(id) { 144 | await this.model.delete(id); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/mixins/ModelMixin.js: -------------------------------------------------------------------------------- 1 | import {API_ROOT, defaultResourceToEntityCase, defaultEntityToResourceRouteCase} from '../constants'; 2 | import ModelTransformer from '../transformers/ModelTransformer'; 3 | import Request from '../json_api/Request'; 4 | 5 | export default class { 6 | static include(modelComponent, attributeClasses, config) { 7 | // Only install axios if it hasn't been set by plugin-axios. 8 | modelComponent.axios = modelComponent.axios || config.axios || null; 9 | 10 | // The global config shared by all models. Set sensible defaults from `constants.js`. 11 | modelComponent.globalJsonApiConfig = { 12 | apiRoot: API_ROOT, 13 | resourceToEntityCase: defaultResourceToEntityCase, 14 | entityToResourceRouteCase: defaultEntityToResourceRouteCase, 15 | attributeClasses: attributeClasses, 16 | ...config, 17 | }; 18 | 19 | // Model-specific config. 20 | modelComponent.jsonApiConfig = {}; 21 | 22 | // Only install the `setAxios` function if it hasn't been set by plugin-axios. 23 | modelComponent.setAxios = modelComponent.setAxios || function (axios) { 24 | this.axios = axios; 25 | }; 26 | 27 | // A cache containing model entity name to transformer mappings. 28 | Object.defineProperty(modelComponent, 'jsonApiTransformer', { 29 | get: function () { 30 | let config = {...this.globalJsonApiConfig, ...this.jsonApiConfig}; 31 | 32 | if (!this.cachedTransformer) { 33 | this.cachedTransformer = new ModelTransformer(this, config); 34 | } 35 | 36 | return this.cachedTransformer; 37 | }, 38 | // Allow HMR to succeed with a module reload. 39 | configurable: true, 40 | }); 41 | 42 | // The JSON:API adapter. 43 | modelComponent.jsonApi = function () { 44 | return new Request(this); 45 | }; 46 | 47 | (modelComponent.globalJsonApiConfig.mixins || []).forEach((mixin) => mixin.include(Request)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/mixins/RestfulActionsMixin.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | static include(klass) { 3 | Object.assign(klass.prototype, { 4 | /* 5 | * The Rails-y REST `index` action. 6 | */ 7 | async index(config = {}) { 8 | return this.get(this.restResourcePath(), {multiplicity: 'many', ...config}); 9 | }, 10 | 11 | /* 12 | * The Rails-y REST `show` action. 13 | */ 14 | async show(id, config = {}) { 15 | return this.get(this.restResourcePath(id), {id, multiplicity: 'one', ...config}); 16 | }, 17 | 18 | /* 19 | * The Rails-y REST `create` action. 20 | */ 21 | async create(data = {}, config = {}) { 22 | return this.post(this.restResourcePath(), data, {multiplicity: 'one', ...config}); 23 | }, 24 | 25 | /* 26 | * The Rails-y REST `update` action. 27 | */ 28 | async update(id, data = {}, config = {}) { 29 | /* 30 | * The JSON:API specification seems to prefer `PATCH` over `PUT`: See 31 | * `https://jsonapi.org/format/#crud-updating-resource-relationships`. 32 | */ 33 | return this.patch(this.restResourcePath(id), data, {id, multiplicity: 'one', ...config}); 34 | }, 35 | 36 | /* 37 | * The Rails-y REST `destroy` action. 38 | */ 39 | async destroy(id, config = {}) { 40 | return this.delete(this.restResourcePath(id), {id, multiplicity: 'none', ...config}); 41 | }, 42 | 43 | /** 44 | * Generates the REST resource path from the model and possible id. 45 | */ 46 | restResourcePath(id = null) { 47 | let modelConfig = {...this.model.globalJsonApiConfig, ...this.model.jsonApiConfig}; 48 | let apiRoot = modelConfig.apiRoot; 49 | let restResource = modelConfig.entityToResourceRouteCase(this.model.entity); 50 | 51 | let path = `${apiRoot !== '/' ? apiRoot : ''}/${restResource}`; 52 | 53 | if (id) { 54 | path = `${path}/${id}`; 55 | } 56 | 57 | return path; 58 | }, 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/transformers/AttributeTransformer.js: -------------------------------------------------------------------------------- 1 | import FieldTransformer from './FieldTransformer'; 2 | 3 | export default class extends FieldTransformer { 4 | constructor(name, attribute, _config = {}) { 5 | super(name); 6 | 7 | this.attribute = attribute; 8 | } 9 | 10 | transform(value, output) { 11 | output[this.name] = value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/transformers/BelongsToRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import RelationTransformer from './RelationTransformer'; 2 | 3 | export default class extends RelationTransformer { 4 | transform(data, output) { 5 | /* 6 | * `null` is an acceptable value for to-one relationships: See 7 | * `https://jsonapi.org/format/#document-resource-object-linkage`. 8 | */ 9 | if (data === null) { 10 | return null; 11 | } 12 | 13 | this.constructor.checkSingleton(data); 14 | 15 | let parentModel = this.relation.parent; 16 | let foreignKey = this.relation.foreignKey; 17 | let {type: resourceType, id} = data; 18 | 19 | this.constructor.checkType(this.resourceToEntityCase(resourceType), parentModel); 20 | 21 | // See `https://vuex-orm.org/guide/model/relationships.html#one-to-one-inverse`. 22 | output[this.name] = {[parentModel.localKey()]: id}; 23 | output[foreignKey] = id; 24 | 25 | return output; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/transformers/DocumentTransformer.js: -------------------------------------------------------------------------------- 1 | import InsertionStore from '../InsertionStore'; 2 | import Utils from '../Utils'; 3 | 4 | export default class { 5 | constructor(database, config) { 6 | this.database = database; 7 | this.resourceToEntityCase = config.resourceToEntityCase; 8 | } 9 | 10 | transform(data) { 11 | let insertionStore = new InsertionStore(); 12 | let primaryData = data.data; 13 | 14 | if (primaryData instanceof Array) { 15 | primaryData.forEach((data) => this.transformResource(data, insertionStore)); 16 | } else { 17 | this.transformResource(primaryData, insertionStore); 18 | } 19 | 20 | let includedData = data.included || []; 21 | includedData.forEach((data) => this.transformResource(data, insertionStore)); 22 | 23 | return insertionStore; 24 | } 25 | 26 | transformResource(data, insertionStore) { 27 | // Convert JSON:API casing to Vuex ORM casing and look up the model. 28 | let type = this.resourceToEntityCase(data.type); 29 | let model = Utils.modelFor(this.database, type); 30 | let resourceId = data.id; 31 | let localKey = model.localKey(); 32 | let record = insertionStore.fetchRecord(type, resourceId, localKey); 33 | 34 | model.jsonApiTransformer.transform(data, record, insertionStore); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/transformers/FieldTransformer.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | constructor(name, _config = {}) { 3 | this.name = name; 4 | } 5 | 6 | transform(_data, _output) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/transformers/HasManyByRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import RelationTransformer from './RelationTransformer'; 2 | 3 | export default class extends RelationTransformer { 4 | transform(data, output) { 5 | this.constructor.checkMany(data); 6 | 7 | let parentModel = this.relation.parent; 8 | 9 | // See `https://vuex-orm.org/guide/model/relationships.html#has-many-by`. 10 | output[this.relation.foreignKey] = data.map(({type: resourceType, id}) => { 11 | this.constructor.checkType(this.resourceToEntityCase(resourceType), parentModel); 12 | 13 | return id; 14 | }); 15 | 16 | return output; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/transformers/HasManyRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import RelationTransformer from './RelationTransformer'; 2 | import Utils from '../Utils'; 3 | 4 | export default class extends RelationTransformer { 5 | transform(data, output, selfId = null, insertionStore = null) { 6 | this.constructor.checkMany(data); 7 | 8 | let selfType = this.relation.model.entity; 9 | let relatedModel = this.relation.related; 10 | let pivot = this.relation.pivot; 11 | let foreignKey = this.relation.foreignKey; 12 | let polymorphicIdKey = this.relation.id; 13 | let polymorphicTypeKey = this.relation.type; 14 | let relatedLocalKey = relatedModel.localKey(); 15 | 16 | /* 17 | * See `https://vuex-orm.org/guide/model/relationships.html#one-to-many`, 18 | * `https://vuex-orm.org/guide/model/relationships.html#many-to-many`, and 19 | * `https://vuex-orm.org/guide/model/relationships.html#many-to-many-polymorphic`. 20 | */ 21 | output[this.name] = data.map(({type: resourceType, id}) => { 22 | let type = this.resourceToEntityCase(resourceType); 23 | 24 | this.constructor.checkType(type, relatedModel); 25 | 26 | /* 27 | * Don't proceed if a pivot model is present; in other words, process direct relations and not "through" 28 | * relations. 29 | */ 30 | if (insertionStore) { 31 | if (!pivot) { 32 | /* 33 | * Instead of creating an embedded record, defer to the `InsertionStore` and make this record visible to later 34 | * JSON:API resources. 35 | */ 36 | let record = insertionStore.fetchRecord(type, id, relatedLocalKey); 37 | 38 | // Fill in the inverse side of this relation. 39 | if (!this.isPolymorphic) { 40 | record[foreignKey] = selfId; 41 | } else { 42 | record[polymorphicIdKey] = selfId; 43 | record[polymorphicTypeKey] = selfType; 44 | } 45 | 46 | return null; 47 | } else { 48 | /** 49 | * Important: Since JSON:API resources contain type information, we want to preserve that even when pivot 50 | * models are involved. This means looking up the base model and doing a reverse lookup of the discriminator 51 | * value to write into discriminator field. 52 | */ 53 | const database = relatedModel.database(); 54 | 55 | const model = Utils.modelFor(database, type); 56 | const baseType = model.baseEntity; 57 | 58 | const record = {[relatedLocalKey]: id}; 59 | 60 | if (baseType) { 61 | const baseModel = Utils.modelFor(database, baseType); 62 | 63 | for (const [discriminator, mappedModel] of Object.entries(baseModel.types())) { 64 | if (type === mappedModel.entity) { 65 | record[baseModel.typeKey || 'type'] = discriminator; 66 | break; 67 | } 68 | } 69 | } 70 | 71 | return record; 72 | } 73 | } else { 74 | return {[relatedLocalKey]: id}; 75 | } 76 | }).filter((record) => !!record); 77 | 78 | return output; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/transformers/HasManyThroughRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import Utils from '../Utils'; 2 | import RelationTransformer from './RelationTransformer'; 3 | 4 | export default class extends RelationTransformer { 5 | transform(data, output) { 6 | this.constructor.checkMany(data); 7 | 8 | /* 9 | * This is not expected to return normally. See this caveat: 10 | * `https://vuex-orm.org/guide/data/inserting-and-updating.html#generating-pivot-records`. 11 | */ 12 | output[this.name] = data.map(() => { 13 | throw Utils.error('Writing directly to a `HasManyThrough` relation is not supported'); 14 | }); 15 | 16 | return output; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/transformers/HasOneRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import RelationTransformer from './RelationTransformer'; 2 | 3 | export default class extends RelationTransformer { 4 | transform(data, output, selfId = null, insertionStore = null) { 5 | /* 6 | * `null` is an acceptable value for to-one relationships: See 7 | * `https://jsonapi.org/format/#document-resource-object-linkage`. 8 | */ 9 | if (data === null) { 10 | return null; 11 | } 12 | 13 | this.constructor.checkSingleton(data); 14 | 15 | let relatedModel = this.relation.related; 16 | let relatedLocalKey = relatedModel.localKey(); 17 | let {type: resourceType, id} = data; 18 | 19 | /* 20 | * See `https://vuex-orm.org/guide/model/relationships.html#one-to-one`, 21 | * `https://vuex-orm.org/guide/model/relationships.html#one-to-one-inverse`, and 22 | * `https://vuex-orm.org/guide/model/relationships.html#one-to-one-polymorphic`. 23 | */ 24 | 25 | let type = this.resourceToEntityCase(resourceType); 26 | 27 | this.constructor.checkType(type, relatedModel); 28 | 29 | if (insertionStore) { 30 | /* 31 | * Instead of creating an embedded record, defer to the `InsertionStore` and make this record visible to later 32 | * JSON:API resources. 33 | */ 34 | let record = insertionStore.fetchRecord(type, id, relatedLocalKey); 35 | 36 | // Fill in the inverse side of this relation. 37 | if (!this.isPolymorphic) { 38 | record[this.relation.foreignKey] = selfId; 39 | } else { 40 | record[this.relation.id] = selfId; 41 | record[this.relation.type] = this.relation.model.entity; 42 | } 43 | } else { 44 | output[this.name] = {[relatedLocalKey]: id}; 45 | } 46 | 47 | return output; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/transformers/ModelTransformer.js: -------------------------------------------------------------------------------- 1 | import Utils from '../Utils'; 2 | import AttributeTransformer from './AttributeTransformer'; 3 | import BelongsToRelationTransformer from './BelongsToRelationTransformer'; 4 | import HasManyByRelationTransformer from './HasManyByRelationTransformer'; 5 | import HasManyRelationTransformer from './HasManyRelationTransformer'; 6 | import HasManyThroughRelationTransformer from './HasManyThroughRelationTransformer'; 7 | import HasOneRelationTransformer from './HasOneRelationTransformer'; 8 | import MorphToRelationTransformer from './MorphToRelationTransformer'; 9 | 10 | export default class { 11 | constructor(model, config) { 12 | this.relationTransformers = {}; 13 | this.attributeTransformers = {}; 14 | this.resourceToEntityCase = config.resourceToEntityCase; 15 | 16 | let { 17 | HasOne, BelongsTo, HasMany, HasManyBy, HasManyThrough, BelongsToMany, MorphTo, MorphOne, MorphMany, MorphToMany, 18 | MorphedByMany, Attr, String, Number, Boolean, Uid, 19 | } = config.attributeClasses; 20 | 21 | Object.entries(model.fields()).forEach(([fieldName, field]) => { 22 | let fieldType = field.constructor; 23 | 24 | switch (fieldType) { 25 | case HasOne: 26 | case MorphOne: 27 | this.relationTransformers[fieldName] = new HasOneRelationTransformer(fieldName, field, config); 28 | break; 29 | case BelongsTo: 30 | this.relationTransformers[fieldName] = new BelongsToRelationTransformer(fieldName, field, config); 31 | break; 32 | case HasMany: 33 | case BelongsToMany: 34 | case MorphMany: 35 | case MorphToMany: 36 | case MorphedByMany: 37 | this.relationTransformers[fieldName] = new HasManyRelationTransformer(fieldName, field, config); 38 | break; 39 | case HasManyBy: 40 | this.relationTransformers[fieldName] = new HasManyByRelationTransformer(fieldName, field, config); 41 | break; 42 | case HasManyThrough: 43 | this.relationTransformers[fieldName] = new HasManyThroughRelationTransformer(fieldName, field, config); 44 | break; 45 | case MorphTo: 46 | this.relationTransformers[fieldName] = new MorphToRelationTransformer(fieldName, field, config); 47 | break; 48 | case Attr: 49 | case String: 50 | case Number: 51 | case Boolean: 52 | case Uid: 53 | this.attributeTransformers[fieldName] = new AttributeTransformer(fieldName, config); 54 | break; 55 | default: 56 | throw Utils.error(`Field type \`${fieldType}\` not recognized for field \`${fieldName}\``); 57 | } 58 | }); 59 | } 60 | 61 | transform(data, output, insertionStore) { 62 | const id = data.id; 63 | 64 | this.attributeTransformers.id.transform(id, output); 65 | 66 | if (data.attributes) { 67 | Object.entries(data.attributes).forEach(([attributeName, value]) => { 68 | // Convert JSON:API casing to Vuex ORM casing and look up the attribute. 69 | attributeName = this.resourceToEntityCase(attributeName); 70 | 71 | let transformer = this.attributeTransformers[attributeName]; 72 | 73 | if (transformer) { 74 | transformer.transform(value, output); 75 | } 76 | }); 77 | } 78 | 79 | if (data.relationships) { 80 | Object.entries(data.relationships).forEach(([relationName, data]) => { 81 | // Convert JSON:API casing to Vuex ORM casing and look up the relation. 82 | relationName = this.resourceToEntityCase(relationName); 83 | 84 | let transformer = this.relationTransformers[relationName]; 85 | 86 | if (transformer) { 87 | transformer.transform(data.data, output, id, insertionStore); 88 | } 89 | }); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/transformers/MorphToRelationTransformer.js: -------------------------------------------------------------------------------- 1 | import RelationTransformer from './RelationTransformer'; 2 | 3 | export default class extends RelationTransformer { 4 | transform(data, output) { 5 | /* 6 | * `null` is an acceptable value for to-one relationships: See 7 | * `https://jsonapi.org/format/#document-resource-object-linkage`. 8 | */ 9 | if (data === null) { 10 | return null; 11 | } 12 | 13 | this.constructor.checkSingleton(data); 14 | 15 | let {type: resourceType, id} = data; 16 | 17 | // See `https://vuex-orm.org/guide/model/relationships.html#one-to-one-polymorphic`. 18 | output[this.relation.id] = id; 19 | output[this.relation.type] = this.resourceToEntityCase(resourceType); 20 | 21 | return output; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/transformers/RelationTransformer.js: -------------------------------------------------------------------------------- 1 | import Utils from '../Utils'; 2 | import FieldTransformer from './FieldTransformer'; 3 | 4 | export default class extends FieldTransformer { 5 | constructor(name, relation, config) { 6 | super(name); 7 | 8 | this.relation = relation; 9 | this.resourceToEntityCase = config.resourceToEntityCase; 10 | this.isPolymorphic = !!(relation.id && relation.type); 11 | } 12 | 13 | transform(_data, _output, _id = null, _insertionStore = null) { 14 | throw Utils.error('Method not implemented'); 15 | } 16 | 17 | static checkType(type, expectedModel) { 18 | /* 19 | * We can guarantee that the model is the bound version generated by a Vuex ORM database (see 20 | * `https://github.com/vuex-orm/vuex-orm/blob/c771f9b/src/database/Database.ts#L186-L216`). 21 | */ 22 | const database = expectedModel.database(); 23 | 24 | const model = Utils.modelFor(database, type); 25 | const baseType = model.baseEntity; 26 | const expectedType = expectedModel.entity; 27 | const expectedBaseType = expectedModel.baseEntity; 28 | 29 | if (type === expectedBaseType) { 30 | // The object's type is a superclass of the expected type: Do nothing and let the operation proceed. 31 | 32 | // TODO: Technically speaking, we should check the mapped STI type. 33 | } else if (baseType === expectedType) { 34 | // The object's type is a subclass of the expected type: Do nothing and let the operation proceed. 35 | } else if (type !== expectedType) { 36 | // If the object's type isn't the expected type and all of the above falls through, just throw an error. 37 | throw Utils.error(`Expected type \`${expectedType}\` but got \`${type}\` in relation`); 38 | } 39 | } 40 | 41 | static checkMany(data) { 42 | if (!(data instanceof Array)) { 43 | throw Utils.error('Expected relation data to be an array'); 44 | } 45 | } 46 | 47 | static checkSingleton(data) { 48 | if (data instanceof Array) { 49 | throw Utils.error('Expected relation data to be an object'); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: { 6 | 'index': path.resolve(__dirname, 'src/index.js'), 7 | 'index.min': path.resolve(__dirname, 'src/index.js'), 8 | }, 9 | devtool: 'source-map', 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: '[name].js', 13 | library: 'vuex-orm-json-api', 14 | libraryTarget: 'umd', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | use: 'babel-loader', 21 | exclude: /node_modules/, 22 | }, 23 | ], 24 | }, 25 | optimization: { 26 | minimizer: [new TerserPlugin({ 27 | sourceMap: true, 28 | include: /\.min\.js$/, 29 | })], 30 | }, 31 | }; 32 | --------------------------------------------------------------------------------