From ff2a62d71b988d69e4dbf28de54e20caf963e5db Mon Sep 17 00:00:00 2001 From: Ralph Huwiler Date: Fri, 22 Feb 2019 22:52:00 +0100 Subject: [PATCH 1/3] feat(variant): different transformer variants can be used --- src/Bumblebee/Manager.js | 22 +++--- src/Bumblebee/Resources/ResourceAbstract.js | 50 ++++++++++++- src/Bumblebee/Scope.js | 28 ++++++- src/Bumblebee/TransformerAbstract.js | 2 +- src/Bumblebee/index.js | 13 ++++ test/transformer-variants.spec.js | 81 +++++++++++++++++++++ test/transformer.spec.js | 2 +- 7 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 test/transformer-variants.spec.js diff --git a/src/Bumblebee/Manager.js b/src/Bumblebee/Manager.js index 72d497f..a516dfd 100644 --- a/src/Bumblebee/Manager.js +++ b/src/Bumblebee/Manager.js @@ -65,6 +65,17 @@ class Manager { this._autoIncludeParents() } + /** + * Allowes setting a custom recursion limit + * + * @param {*} limit + */ + setRecursionLimit (limit) { + this._recursionLimit = limit + + return this + } + /** * Create a serializer * @@ -148,17 +159,6 @@ class Manager { parsed.forEach(this.requestedIncludes.add, this.requestedIncludes) } - /** - * Allowes setting a custom recursion limit - * - * @param {*} limit - */ - setRecursionLimit (limit) { - this._recursionLimit = limit - - return this - } - /** * Parses the request object from the context and extracts the requested includes * diff --git a/src/Bumblebee/Resources/ResourceAbstract.js b/src/Bumblebee/Resources/ResourceAbstract.js index e655980..1e6b9be 100644 --- a/src/Bumblebee/Resources/ResourceAbstract.js +++ b/src/Bumblebee/Resources/ResourceAbstract.js @@ -11,10 +11,14 @@ class ResourceAbstract { * Constractor for the ResourceAbstract * This allowes to set data and transformer while creating an instance */ - constructor (data, transformer) { + constructor (data, trans) { this.data = data - this.transformer = transformer this.meta = null + + let { transformer, variant } = this._separateTransformerAndVariation(trans) + + this.transformer = transformer + this.variant = variant } /** @@ -75,6 +79,48 @@ class ResourceAbstract { getPagination () { return this.pagination } + + /** + * Set the transformer variant to be used for this resource + * + * @param {Object} pagination + */ + setVariant (variant) { + if (variant) { + this.variant = variant + } + + return this + } + + /** + * Returns the transformer variant + */ + getVariant () { + return this.variant + } + + /** + * When a transformer string is passed with a variation defined in dot-notation + * we will split the string into transformer and variant + */ + _separateTransformerAndVariation (transformerString) { + // This feature is only available when a string binding is used + if (typeof transformerString !== 'string') { + return { transformer: transformerString, variant: null } + } + + let regex = /(.*)\.(.*)/ + + let matches = transformerString.match(regex) + + // if the string did not contain a variation use the + // transformerString is used and the variation is set to null + let transformer = matches ? matches[1] : transformerString + let variant = matches ? matches[2] : null + + return { transformer, variant } + } } module.exports = ResourceAbstract diff --git a/src/Bumblebee/Scope.js b/src/Bumblebee/Scope.js index ecd4a8f..49f1656 100644 --- a/src/Bumblebee/Scope.js +++ b/src/Bumblebee/Scope.js @@ -118,7 +118,7 @@ class Scope { // get a transformer instance and tranform data let transformerInstance = this._getTransformerInstance(transformer) - let transformedData = await transformerInstance.transform(await data, this._ctx) + let transformedData = await this._dispatchToTransformerVariant(transformerInstance, await data, this._ctx) // if this transformer has includes defined, // figure out which includes should be run and run requested includes @@ -200,6 +200,32 @@ class Scope { throw new Error('A transformer must be a function or a class extending TransformerAbstract') } + /** + * Checks if any variants are defined and calls the corresponding transform method + * + * @param {*} transformerInstance + * @param {*} data + * @param {*} ctx + */ + async _dispatchToTransformerVariant (transformerInstance, data, ctx) { + let variant = this._resource.getVariant() + + // if a variant was defined, we construct the name for the transform mehod + // otherwise, the default transformer method 'transform' is called + let transformMethodName = variant + ? `transform${variant.charAt(0).toUpperCase()}${variant.slice(1)}` + : 'transform' + + // Since the user can pass anything as a variant name, we need to + // validate that the transformer method exists. + if (!(transformerInstance[transformMethodName] instanceof Function)) { + throw new Error(`A transformer method '${transformMethodName}' could not be found in '${transformerInstance.constructor.name}'`) + } + + // now we call the transformer method on the transformer and return the data + return transformerInstance[transformMethodName](data, this._ctx) + } + /** * Check if the used transformer has any includes defined * diff --git a/src/Bumblebee/TransformerAbstract.js b/src/Bumblebee/TransformerAbstract.js index 2d29eb9..dc7304a 100644 --- a/src/Bumblebee/TransformerAbstract.js +++ b/src/Bumblebee/TransformerAbstract.js @@ -28,7 +28,7 @@ class TransformerAbstract { * Implementation required */ transform () { - throw new Error('You have to implement the method transform!') + throw new Error('You have to implement the method transform or specify a variant when calling the transformer!') } /** diff --git a/src/Bumblebee/index.js b/src/Bumblebee/index.js index f2943c3..21f5d4e 100644 --- a/src/Bumblebee/index.js +++ b/src/Bumblebee/index.js @@ -25,6 +25,7 @@ class Bumblebee { instance._data = data instance._dataType = instance._determineDataType(data) instance._transformer = transformer + instance._variant = null // set pagination, context and meta properties to null instance._pagination = null @@ -143,6 +144,17 @@ class Bumblebee { return this } + /** + * Set the transformer variant + * + * @param {String} variant + */ + usingVariant (variant) { + this._variant = variant + + return this + } + /** * Allows you to set the adonis context if you are not * using the 'transform' object from the http context. @@ -230,6 +242,7 @@ class Bumblebee { resourceInstance.setMeta(this._meta) resourceInstance.setPagination(this._pagination) + resourceInstance.setVariant(this._variant) return resourceInstance } diff --git a/test/transformer-variants.spec.js b/test/transformer-variants.spec.js new file mode 100644 index 0000000..c3bbc6f --- /dev/null +++ b/test/transformer-variants.spec.js @@ -0,0 +1,81 @@ +'use strict' + +/** + * adonis-bumblebee + * + * (c) Ralph Huwiler + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const test = require('japa') +const { ioc } = require('@adonisjs/fold') + +const Bumblebee = require('../src/Bumblebee') +const TransformerAbstract = require('../src/Bumblebee/TransformerAbstract') + +class IDTransformer extends TransformerAbstract { + transformVariant1 (model) { + return { + id: model.item_id + } + } + + transformVariant2 (model) { + return { + identifier: model.item_id + } + } +} + +test.group('Transformer Variants', () => { + test('a specific variant can be used for the transformer method', async (assert) => { + let data = { item_id: 3 } + + let transformedVariant1 = await Bumblebee.create() + .item(data) + .transformWith(IDTransformer) + .usingVariant('variant1') + .toJSON() + + assert.equal(transformedVariant1.id, 3) + + let transformedVariant2 = await Bumblebee.create() + .item(data) + .transformWith(IDTransformer) + .usingVariant('variant2') + .toJSON() + + assert.equal(transformedVariant2.identifier, 3) + }) + + test('a transformer can be defined using dot-notation', async (assert) => { + ioc.bind('App/Transformers/IDTransformer', () => new IDTransformer()) + + let data = { item_id: 3 } + + let transformedVariant1 = await Bumblebee.create() + .item(data) + .transformWith('App/Transformers/IDTransformer.variant1') + .toJSON() + + assert.equal(transformedVariant1.id, 3) + }) + + test('an error is thrown if an invalid variant is passed', async (assert) => { + assert.plan(1) + + let data = { item_id: 3 } + + try { + await Bumblebee.create() + .item(data) + .transformWith(IDTransformer) + .usingVariant('variantX') + .toJSON() + } catch ({ message }) { + assert.equal(message, 'A transformer method \'transformVariantX\' could not be found in \'IDTransformer\'') + } + }) +}) diff --git a/test/transformer.spec.js b/test/transformer.spec.js index e2e2ea4..4db64ce 100644 --- a/test/transformer.spec.js +++ b/test/transformer.spec.js @@ -102,7 +102,7 @@ test.group('Transformer', () => { .transformWith(InvalidTransformer) .toJSON() } catch ({ message }) { - assert.equal(message, 'You have to implement the method transform!') + assert.equal(message, 'You have to implement the method transform or specify a variant when calling the transformer!') } }) From c60df07ee7954a3a305d26df3e787ff0d14853fd Mon Sep 17 00:00:00 2001 From: Ralph Huwiler Date: Fri, 22 Feb 2019 23:07:25 +0100 Subject: [PATCH 2/3] feat(variant): added test for shorthand methods --- test/transformer-variants.spec.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/transformer-variants.spec.js b/test/transformer-variants.spec.js index c3bbc6f..c55e15c 100644 --- a/test/transformer-variants.spec.js +++ b/test/transformer-variants.spec.js @@ -63,6 +63,22 @@ test.group('Transformer Variants', () => { assert.equal(transformedVariant1.id, 3) }) + test('variants can be used in shorthand form', async (assert) => { + ioc.bind('App/Transformers/IDTransformer', () => new IDTransformer()) + + let data = { item_id: 3 } + + let transformedVariant1 = await Bumblebee.create() + .item(data, 'App/Transformers/IDTransformer.variant1') + + assert.equal(transformedVariant1.id, 3) + + let transformedVariant2 = await Bumblebee.create() + .collection([data], 'App/Transformers/IDTransformer.variant2') + + assert.equal(transformedVariant2[0].identifier, 3) + }) + test('an error is thrown if an invalid variant is passed', async (assert) => { assert.plan(1) From 3cdb1bbdb3a856817d80d0fe9125d9124e26fbad Mon Sep 17 00:00:00 2001 From: Ralph Huwiler Date: Sat, 23 Feb 2019 14:58:42 +0100 Subject: [PATCH 3/3] feat(variant): update documentation --- README.md | 83 ++++++++++++++++++++++++------- test/transformer-variants.spec.js | 69 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d6077e6..8c52cda 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ presentation and transformation layer for complex data output. * [Default Include](#default-include) * [Available Include](#available-include) * [Parse available includes automatically](#parse-available-includes-automatically) + * [Transformer Variants](#transformer-variants) * [EagerLoading](#eagerloading) * [Serializers](#serializers) * [PlainSerializer](#plainserializer) @@ -179,12 +180,12 @@ return transform.collection(users, 'App/Transformers/UserTransformer') *Note:* Passing the Transformer as the second argument will terminate the fluent interface. If you want to chain more methods after the call to `collection` or `item` you should only pass the first argument and then use the `transformWith` -method to define the transformer. See [Fluent Interface](#fluent-interface) +method to define the transformer. See [Fluent Interface](#fluent-interface). ### Including Data -When transforming a model you may want to include some additional data. For +When transforming a model, you may want to include some additional data. For example, you may have a book model and want to include the author for the book in the same resource. Include methods let you do just that. @@ -195,11 +196,11 @@ Includes defined in the `defaultInclude` getter will always be included in the returned data. You have to specify the name of the include by returning an array of all -includes from the `defaultInclude` getter. Then you create an additional method -for each include, named like in the example: `include{Name}` +includes from the `defaultInclude` getter. Then you create an additional method +for each include, named like in the example: `include{Name}`. The include method returns a new resource, that can either be an `item` or a -`collection`. See [Resources](#resources) +`collection`. See [Resources](#resources). ```js class BookTransformer extends BumblebeeTransformer { @@ -225,7 +226,8 @@ class BookTransformer extends BumblebeeTransformer { module.exports = BookTransformer ``` -*Note:* Just like in the transform method, you can also access to the `context` through the second argument. +*Note:* Just like in the transform method, you can also access to the `context` +through the second argument. *Note:* If you have hyphen or underscore separated properties, you would name the include function in camelCase. The conversion is done automatically. @@ -266,20 +268,64 @@ To include this resource you call the `include()` method before transforming. return transform.include('author').item(book, BookTransformer) ``` -These includes can be nested with dot notation too, to include resources within other resources. +These includes can be nested with dot notation too, to include resources within +other resources. ```js return transform.include('author,publisher.something').item(book, BookTransformer) ``` -### Parse available includes automatically +#### Parse available includes automatically In addition to the previously mentioned `include` method, you can also enable `parseRequest` in the config file. Now bumblebee will automatically parse the `?include=` GET parameter and include the requested resources. +### Transformer Variants + +Sometimes you may want to transform some model in a slitely different way while +sill utilizing existing include methods. To use out book example, you may have +an api endpoint that returns a list of all books, but you don't want to include +the summary of the book in this response to save on data. However, when +requesting a single book you want the summary to be included. + +You could define a separate transformer for this, but it would be much easier if +you could reuse the existing book transformer. This is where transform variants +come in play. + +```js +class BookTransformer extends BumblebeeTransformer { + transform (book) { + return { + id: book.id, + title: book.title, + year: book.yr + } + } + + transformWithSummary (book) { + return { + ...this.transform(book), + summary: book.summary + } + } +} + +module.exports = BookTransformer +``` + +We define a `transformWithSummary` method that calls our existing `transform` +method and adds the book summary to the result. + +Now we can use this variant by specifing it as follows: + +```js +return transform.collection(books, 'App/Transformers/BookTransformer.withSummary') +``` + + ## EagerLoading @@ -390,9 +436,9 @@ There is one major drawback to this serializer. It does not play nicely with met Since you cannot mix an Array and Objects in JSON, the serializer has to add a `data` property if you use metadata on a collection. The same is true if you use -pagination. This is why we do not recommend using the `PlainSerializer` when -using these features. But other than that, this serializer works great for small -and simple APIs. +pagination. This is why we do not recommend using `PlainSerializer` when using +these features. But other than that, this serializer works great for small and +simple APIs. ### DataSerializer @@ -458,6 +504,7 @@ available on the `transform` object in the context and from `Bumblebee.create()` - `paginate(data)` - `meta(metadata)` - `transformWith(transformer)` +- `usingVariant(variant)` - `withContext(ctx)` - `include(include)` - `setSerializer(serializer)` @@ -483,15 +530,17 @@ let transformed = await Bumblebee.create() .toJSON() ``` -You can use the same methods as in a controller. With one difference: If -you need the `context` inside the transformer, you have to set it with the +You can use the same methods as in a controller. With one difference: If you +need the `context` inside the transformer, you have to set it with the `.withContext(ctx)` method since it is not automatically injected. ## Credits -Special thanks to the creator(s) of -[Fractal](https://fractal.thephpleague.com/), a PHP API transformer that was the -main inspiration for this package. Also, a huge thank goes to the creator(s) of -[AdonisJS](http://adonisjs.com/) for creating such an awesome framework. +Special thanks to the creator(s) of [Fractal], a PHP API transformer that was +the main inspiration for this package. Also, a huge thank goes to the creator(s) +of [AdonisJS] for creating such an awesome framework. + +[Fractal]: https://fractal.thephpleague.com +[AdonisJS]: http://adonisjs.com \ No newline at end of file diff --git a/test/transformer-variants.spec.js b/test/transformer-variants.spec.js index c55e15c..747055f 100644 --- a/test/transformer-variants.spec.js +++ b/test/transformer-variants.spec.js @@ -29,6 +29,45 @@ class IDTransformer extends TransformerAbstract { } } +class IDIncludeTransformer extends TransformerAbstract { + static get availableInclude () { + return [ + 'both' + ] + } + + transformVariant1 (model) { + return { + id: model.item_id + } + } + + transformVariant2 (model) { + return { + identifier: model.item_id + } + } + + includeBoth (model) { + return this.item(model, 'App/Transformers/IDIncludeTransformer.variant2') + } +} + +class IDRefTransformer extends TransformerAbstract { + transform (model) { + return { + id: model.item_id + } + } + + transformWithName (model) { + return { + ...this.transform(model), + name: model.name + } + } +} + test.group('Transformer Variants', () => { test('a specific variant can be used for the transformer method', async (assert) => { let data = { item_id: 3 } @@ -79,6 +118,36 @@ test.group('Transformer Variants', () => { assert.equal(transformedVariant2[0].identifier, 3) }) + test('includes can use a variant', async (assert) => { + ioc.bind('App/Transformers/IDIncludeTransformer', () => new IDIncludeTransformer()) + + let data = { item_id: 3 } + + let transformed = await Bumblebee.create() + .include('both') + .item(data, 'App/Transformers/IDIncludeTransformer.variant1') + + assert.deepEqual(transformed, { + id: 3, + both: { identifier: 3 } + }) + }) + + test('a transformer variant can reference the default transformer', async (assert) => { + ioc.bind('App/Transformers/IDRefTransformer', () => new IDRefTransformer()) + + let data = { item_id: 3, name: 'Leta' } + + let transformed = await Bumblebee.create() + .include('both') + .item(data, 'App/Transformers/IDRefTransformer.withName') + + assert.deepEqual(transformed, { + id: 3, + name: 'Leta' + }) + }) + test('an error is thrown if an invalid variant is passed', async (assert) => { assert.plan(1)