diff --git a/docs/content/en/api/model-options.md b/docs/content/en/api/model-options.md index 8fa5179..2d479b7 100644 --- a/docs/content/en/api/model-options.md +++ b/docs/content/en/api/model-options.md @@ -7,10 +7,11 @@ category: API ## Global Options -It's recommended to define the global options in your [Base Model](/configuration#creating-a-base-model), +It's recommended to define the global options in your [Base Model](/configuration#creating-a-base-model), in order to abstract configuration from your models. ### `$http` + - Returns: `HTTP Client Instance` Instance of the HTTP client which is used to make requests. @@ -18,6 +19,7 @@ Instance of the HTTP client which is used to make requests. See [Installation](/installation) ### `baseURL` + - Returns: `string` Base URL which is used and prepended to make requests. @@ -31,6 +33,7 @@ baseURL() { ``` ### `request` + - Arguments: `(config)` - Returns: `HTTP Client Request` @@ -45,6 +48,7 @@ request(config) { ``` ### `parameterNames` + - Returns: `object` This method can be overridden in the model to customize the name of the query parameters. @@ -66,34 +70,42 @@ parameterNames() { ``` #### `include` + - Default: `include` - Returns: `string` #### `filter` + - Default: `filter` - Returns: `string` #### `sort` + - Default: `sort` - Returns: `string` #### `fields` + - Default: `fields` - Returns: `string` #### `append` + - Default: `append` - Returns: `string` #### `page` + - Default: `page` - Returns: `string` #### `limit` + - Default: `limit` - Returns: `string` ### `formData` + - Returns: `object` This method can be overridden in the model to configure `object-to-formdata`. @@ -112,6 +124,7 @@ formData() { ``` ### `stringifyOptions` + - Default: `{ encode: false, arrayFormat: 'comma' }` - Returns: `object` @@ -133,6 +146,7 @@ stringifyOptions() { These are model-related options. ### `resource` + - Returns: `string` Resource route of the model which is used to build the query. @@ -146,6 +160,7 @@ resource() { ``` ### `primaryKey` + - Default: `id` - Returns: `string` @@ -159,10 +174,26 @@ primaryKey() { } ``` +### `wrap` + +- Default: `null` +- Returns: `string` + +The "data" wrapper that should be checked when retrieving models. + +See [Configuration](/configuration#changing-the-wrapper) + +```js +wrap() { + return 'data' +} +``` + ### `relations` + - Returns: `object` -This method can be implemented in the model to apply model instances to eager loaded relationships. +This method can be implemented in the model to apply model instances to eager loaded relationships. It works for collections too. It must return an object, which the key is the property of the relationship, and the value is the @@ -179,6 +210,7 @@ relations() { ``` ### `hasMany` + - Arguments: `(model)` - Returns: `Model` diff --git a/docs/content/en/api/query-builder-methods.md b/docs/content/en/api/query-builder-methods.md index 2dbaa6d..193bd22 100644 --- a/docs/content/en/api/query-builder-methods.md +++ b/docs/content/en/api/query-builder-methods.md @@ -6,6 +6,7 @@ category: API --- ## `include` + - Arguments: `(...args)` - Returns: `self` @@ -26,6 +27,7 @@ await Model.include(['user', 'category']) `with` is an alias of this method. ## `append` + - Arguments: `(...args)` - Returns: `self` @@ -44,17 +46,20 @@ await Model.append(['likes', 'shares']) ``` ## `select` + - Arguments: `(...fields)` - Returns: `self` Set the columns to be selected. #### Single entity + ```js await Model.select(['title', 'content']) ``` #### Related entities + ```js await Post.select({ posts: ['title', 'content'], @@ -63,6 +68,7 @@ await Post.select({ ``` ## `where` + - Arguments: `(field, value)` - Returns: `self` @@ -89,6 +95,7 @@ await Model.where({ user: { status: 'active' } }) ``` ## `whereIn` + - Arguments: `(field, array)` - Returns: `self` @@ -115,13 +122,14 @@ await Model.where({ user: { id: [1, 2, 3] } }) ``` ## `orderBy` + - Arguments: `(...args)` - Returns: `self` Add an "order by" clause to the query. ```js -await Model.orderBy('-created_at', 'category_id') +await Model.orderBy('-created_at', 'category_id') ``` #### Array @@ -129,10 +137,11 @@ await Model.orderBy('-created_at', 'category_id') Available in version >= v1.8.0 ```js -await Model.orderBy(['-created_at', 'category_id']) +await Model.orderBy(['-created_at', 'category_id']) ``` ## `page` + - Arguments: `(value)` - Returns: `self` @@ -143,6 +152,7 @@ await Model.page(1) ``` ## `limit` + - Arguments: `(value)` - Returns: `self` @@ -153,6 +163,7 @@ await Model.limit(20) ``` ## `params` + - Arguments: `(payload)` - Returns: `self` @@ -161,24 +172,25 @@ Add custom parameters to the query. - ```js - await Model.params({ - foo: 'bar', - baz: true - }) - ``` +```js +await Model.params({ + foo: 'bar', + baz: true +}) +``` - ```http request - GET /resource?foo=bar&baz=true - ``` +```http request +GET /resource?foo=bar&baz=true +``` ## `when` + Available in version >= v1.10.0 - Arguments: `(value, callback)` @@ -192,7 +204,33 @@ const search = 'foo' await Model.when(search, (query, value) => query.where('search', value)) ``` +## `wrappedBy` + +Available in version >= v1.12.0 + +- Arguments: `(value)` +- Returns: `self` + +Change the `wrap()` data wrapper for this request. + +```js +await Model.wrappedBy('somewrapper').get() +``` + +## `nowrap` + +Available in version >= v1.12.0 + +- Returns: `self` + +Remove the `wrap()` data wrapper for this request to return the raw response. + +```js +await Model.nowrap().get() +``` + ## `custom` + - Arguments: `(...args)` - Returns: `self` @@ -201,38 +239,39 @@ Build custom endpoints. - ```js - await Post.custom('posts/latest') - ``` +```js +await Post.custom('posts/latest') +``` - ```http request - GET /posts/latest - ``` +```http request +GET /posts/latest +``` - ```js - const user = new User({ id: 1 }) - const post = new Post() +```js +const user = new User({ id: 1 }) +const post = new Post() - await Post.custom(user, post, 'latest') - ``` +await Post.custom(user, post, 'latest') +``` - ```http request - GET /users/1/posts/latest - ``` +```http request +GET /users/1/posts/latest +``` ## `config` + Available in version >= v1.8.0 - Arguments: `(config)` @@ -250,6 +289,7 @@ await Model.config({ ``` ## `get` + - Returns: `Collection | { data: Collection }` Execute the query and get all results. @@ -261,6 +301,7 @@ await Model.get() `all` is an alias of this method. ## `first` + - Returns: `Model | { data: Model }` Execute the query and get the first result. @@ -270,6 +311,7 @@ await Model.first() ``` ## `find` + - Arguments: `(identifier)` - Returns: `Model | { data: Model }` @@ -280,6 +322,7 @@ await Model.find(1) ``` ## `$get` + - Returns: `Collection` Execute the query and get all results. @@ -294,6 +337,7 @@ They handle and unwrap responses within "data". `$all` is an alias of this method. ## `$first` + - Returns: `Model` Execute the query and get the first result. @@ -302,10 +346,11 @@ Execute the query and get the first result. await Model.$first() ``` -These `$`-prefixed convenience methods always return the requested content. +These `$`-prefixed convenience methods always return the requested content. They handle and unwrap responses within "data". ## `$find` + - Arguments: `(identifier)` - Returns: `Model` @@ -315,5 +360,5 @@ Find a model by its primary key. await Model.$find(1) ``` -These `$`-prefixed convenience methods always return the requested content. +These `$`-prefixed convenience methods always return the requested content. They handle and unwrap responses within "data". diff --git a/docs/content/en/configuration.md b/docs/content/en/configuration.md index 68e138b..c9eb4cd 100644 --- a/docs/content/en/configuration.md +++ b/docs/content/en/configuration.md @@ -9,11 +9,12 @@ category: Getting Started See the [API reference](/api/model-options) for a list of available options. -The first step is to create a base model to define the default options, in order to abstract configuration -from your models. It should extend the +The first step is to create a base model to define the default options, in order to abstract configuration +from your models. It should extend the [Base Model](https://github.com/robsontenorio/vue-api-query/blob/master/src/Model.js) of [vue-api-query](https://github.com/robsontenorio/vue-api-query). The base model must implement two methods: + - `baseURL` - The base url of your REST API. - `request` - The default request method. @@ -43,9 +44,11 @@ export default class Model extends BaseModel { Now let's create our domain models that extends the base model. We can create as many models as we like. Each model must implement: + - `resource` - The resource route of the model. We can create a **User** model like this: + ```js{}[~/models/User.js] import Model from './Model' @@ -71,7 +74,7 @@ export default class User extends Model { } // Computed properties are reactive -> user.fullName - // Make sure to use "get" prefix + // Make sure to use "get" prefix get fullName () { return `${this.firstname} ${this.lastname}` } @@ -90,6 +93,7 @@ export default class User extends Model { If we are working on a Typescript project, we can infer the types of the fields, so we have intellisense. #### Directly in Model + ```ts{}[~/models/User.ts] import Model from './Model' @@ -103,6 +107,7 @@ export default class User extends Model { ``` #### Using an Interface + ```ts{}[~/models/User.ts] import Model from './Model' @@ -149,6 +154,36 @@ export default class Post extends Model { This **Post** model will build the query using the `slug` as primary key: `/posts/{slug}` +## Changing the Wrapper + +Available in version >= v1.12.0 + +By default, the `wrap` is set to `null`. + +See the [API reference](/api/model-options#wrap) + +It's possible to change the wrapper of a model response by implementing the `wrap` method. +This way, the specified key will be used to unwrap the data. + +Let's create a **Post** model and set its wrap key to `data`. + +```js{}[~/models/Post.js] +import Model from './Model' + +export default class Post extends Model { + // Set the resource route of the model + resource() { + return 'posts' + } + + // Define the response wrapper + wrap() { + return 'data' + } +``` + +This **Post** model will now look inside the API response top level `data` property when retrieving data and initializing your models. + ## Defining Relationships It's also possible to define the relationships of our models. By doing this, model instances will be automatically @@ -158,7 +193,7 @@ applied to relationships, giving you access to all of their features. See the [API reference](/api/model-options#relations) -For relationships that have been eager loaded, we only need to implement the `relations` method +For relationships that have been eager loaded, we only need to implement the `relations` method to apply their model instances. It works for collections too. The `relations` method must return an object, which the key is the property of the relationship, and the value is the @@ -191,7 +226,7 @@ export default class Post extends Model { } ``` -Now we can easily access an instance of the **User** model containing the eager loaded data +Now we can easily access an instance of the **User** model containing the eager loaded data using the specified key: `post.user` The `relations` method also support nested keys, by dot notation: @@ -247,7 +282,7 @@ export default class User extends Model { } // Computed properties are reactive -> user.fullName - // Make sure to use "get" prefix + // Make sure to use "get" prefix get fullName () { return `${this.firstname} ${this.lastname}` } @@ -290,7 +325,7 @@ export default class Model extends BaseModel { const customParams = { include: 'include_custom' } - + return { ...defaultParams, ...customParams } } } @@ -300,10 +335,10 @@ export default class Model extends BaseModel { See the [API reference](/api/model-options#stringifyOptions) and [qs](https://github.com/ljharb/qs#stringifying) -We may also need to configure the parser to match our needs. By default, it is configured to match +We may also need to configure the parser to match our needs. By default, it is configured to match `spatie/laravel-query-builder`, which uses `comma` array format. -If we want, for example, to change this behaviour to `indices`, we can configure the stringify options of `qs` +If we want, for example, to change this behaviour to `indices`, we can configure the stringify options of `qs` by overriding the `stringifyOptions` method. We can globally configure this in the [Base Model](/configuration#creating-a-base-model): @@ -329,10 +364,10 @@ export default class Model extends BaseModel { const customParams = { include: 'include_custom' } - + return { ...defaultParams, ...customParams } } - + // Configure qs stringifyOptions() { return { @@ -373,17 +408,17 @@ export default class Model extends BaseModel { const customParams = { include: 'include_custom' } - + return { ...defaultParams, ...customParams } } - + // Configure qs stringifyOptions() { return { arrayFormat: 'indices' } } - + // Configure object-to-formadata formData() { return { diff --git a/index.d.ts b/index.d.ts index c3e34b9..4eb47da 100644 --- a/index.d.ts +++ b/index.d.ts @@ -368,6 +368,14 @@ export class Model extends StaticModel { */ primaryKey (): string + /** + * The "data" wrapper that should be checked when retrieving models + * + * @see {@link https://robsontenorio.github.io/vue-api-query/api/model-options#wrap|API Reference} + * @see {@link https://robsontenorio.github.io/vue-api-query/configuration#changing-the-wrapper|Configuration} + */ + wrap (): string + /** * This method can be used to lazy load relationships of a model and apply model instances to them. * @@ -917,6 +925,20 @@ declare class Builder { */ limit (number: number): this + /** + * Change the `wrap()` data wrapper for this request. + * + * @see {@link https://robsontenorio.github.io/vue-api-query/api/query-builder-methods#wrappedBy|API Reference} + */ + wrappedBy (attributes: string[]): this + + /** + * Remove the `wrap()` data wrapper for this request to return the raw response. + * + * @see {@link https://robsontenorio.github.io/vue-api-query/api/query-builder-methods#nowrap|API Reference} + */ + nowrap (attributes: string[]): this + /** * Add custom parameters to the query. * diff --git a/src/Model.js b/src/Model.js index 533990f..f1eda56 100644 --- a/src/Model.js +++ b/src/Model.js @@ -12,6 +12,8 @@ export default class Model extends StaticModel { if (attributes.length === 0) { this._builder = new Builder(this) + // Set the default data wrapper + this._wrapper = this.wrap() } else { Object.assign(this, ...attributes) this._applyRelations(this) @@ -56,6 +58,28 @@ export default class Model extends StaticModel { return 'id' } + /** + * The "data" wrapper that should be checked when retrieving models + * + * @return {string|null} + */ + wrap() { + return null + } + + /** + * Unwrap the response using the property defined in the wrap() method + * + * @return {object|array} The unwraped response + */ + _unwrap(response) { + if (this._wrapper) { + return response[this._wrapper] || response + } else { + return response + } + } + getPrimaryKey() { return this[this.primaryKey()] } @@ -262,6 +286,26 @@ export default class Model extends StaticModel { return this } + /** + * The "data" wrapper that will override the wrap() method + * + * @param {string} wrap The new wrapper for this one request + * + * @return {string|null} + */ + wrappedBy(wrap) { + this._wrapper = wrap + return this + } + + /** + * Disable wrapping for this one request + */ + nowrap() { + this._wrapper = null + return this + } + /** * Result */ @@ -365,7 +409,7 @@ export default class Model extends StaticModel { item = response[0] } - return item || {} + return this._unwrap(item || {}) }) } @@ -386,7 +430,7 @@ export default class Model extends StaticModel { method: 'GET' }) ).then((response) => { - return this._applyInstance(response.data) + return this._applyInstance(this._unwrap(response.data)) }) } @@ -421,7 +465,7 @@ export default class Model extends StaticModel { response.data = collection } - return response.data + return this._unwrap(response.data) }) } diff --git a/src/StaticModel.js b/src/StaticModel.js index 61a25db..da2caec 100644 --- a/src/StaticModel.js +++ b/src/StaticModel.js @@ -88,6 +88,20 @@ export default class StaticModel { return self } + static wrappedBy(value) { + let self = this.instance() + self.wrappedBy(value) + + return self + } + + static nowrap() { + let self = this.instance() + self.nowrap() + + return self + } + static custom(...args) { let self = this.instance() self.custom(...args) diff --git a/tests/model.test.js b/tests/model.test.js index 8538181..72e8198 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -55,7 +55,7 @@ describe('Model methods', () => { }) }) - test('$first() returns first object in array as instance of such Model', async () => { + test('$first() returns first object in array as instance of such Model with "data" wrapper', async () => { axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) const post = await Post.$first() @@ -285,6 +285,83 @@ describe('Model methods', () => { expect(postsAll).toStrictEqual(postsGet) }) + test('find() handles request with "data" wrapper when wrap() is set to "data"', async () => { + // Set the wrap method to 'data' + Post.prototype['wrap'] = () => { + return 'data' + } + + axiosMock.onGet('http://localhost/posts/1').reply(200, postEmbedResponse) + + const post = await Post.find(1) + + expect(post).toEqual(postEmbedResponse.data) + expect(post).toBeInstanceOf(Post) + expect(post.user).toBeInstanceOf(User) + post.relationships.tags.data.forEach((tag) => { + expect(tag).toBeInstanceOf(Tag) + }) + }) + + test('get() handles request with "data" wrapper when wrap() is set to "data"', async () => { + // Set the wrap method to 'data' + Post.prototype['wrap'] = () => { + return 'data' + } + + axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) + + const posts = await Post.get() + + expect(posts).toEqual(postsEmbedResponse.data) + }) + + test('first() returns first object in array as instance of such Model with "data" wrapper when wrap() is set to "data"', async () => { + // Set the wrap method to 'data' + Post.prototype['wrap'] = () => { + return 'data' + } + + axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) + + const post = await Post.first() + + expect(post).toEqual(postsEmbedResponse.data[0]) + expect(post).toBeInstanceOf(Post) + expect(post.user).toBeInstanceOf(User) + post.relationships.tags.forEach((tag) => { + expect(tag).toBeInstanceOf(Tag) + }) + }) + + test('nowrap().get() returns the full response with the "data" wrapper even though the wrap method is set', async () => { + // Set the wrap method to 'data' + Post.prototype['wrap'] = () => { + return 'data' + } + + axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) + + const posts = await Post.nowrap().get() + + expect(posts).toEqual(postsEmbedResponse) + expect(posts.data[0]).toEqual(postsEmbedResponse.data[0]) + }) + + test('wrappedBy().get() returns the full response with the "data" wrapper even though the wrap method is set to `test`', async () => { + // Set the wrap method to 'data' + Post.prototype['wrap'] = () => { + return 'test' + } + + axiosMock.onGet('http://localhost/posts').reply(200, postsEmbedResponse) + + const posts = await Post.wrappedBy('data').get() + + expect(posts).toEqual(postsEmbedResponse.data) + expect(posts[0]).toEqual(postsEmbedResponse.data[0]) + }) + test('save() method makes a POST request when ID of object does not exists', async () => { let post const _postResponse = {