How do I serialize a has-many-through relationship with Ember's JSONAPIAdapter? - ember.js

I have three models: User, Group, and Membership. A User has many Groups through Memberships. I have a form to invite a new user and assign them to zero or more groups all at once.
What I Want
The JSON I expect to send to the server for POST /users looks like
{
data: {
type: 'user',
id: null,
attributes: { name: 'Sam Sample' }
},
relationships: {
memberships: {
data: [
{
type: 'membership',
id: null,
relationships: {
group: {
data: {
type: 'group',
id: 12345
}
}
}
},
{
type: 'membership',
id: null,
relationships: {
group: {
data: {
type: 'group',
id: 67890
}
}
}
}
]
}
}
}
What I Tried
I tried adding serialize: true to the relevant serializers:
// serializer:user
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
memberships: { serialize: true }
}
})
// serializer:membership
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
group: { serialize: true }
}
})
That gets me some of the JSON I expect, but not all of it. Specifically, I get the membership objects, but not the groups within them
{
data: {
type: 'user',
id: null,
attributes: { name: 'Sam Sample' }
},
relationships: {
memberships: {
data: [
{
type: 'membership',
id: null
},
{
type: 'membership',
id: null
}
]
}
}
}

Related

Mirage server GETs data but POST fails

I have the mirage models:
// mirage/models/country.js
import { Model, belongsTo, hasMany } from 'miragejs';
export default Model.extend({
name: '',
iso3166_1_alpha3: '',
capitol_city: belongsTo('city', {inverse: null}),
cities: hasMany('city', {inverse: 'country'})
});
and:
// mirage/models/city.js
import { Model, belongsTo } from 'miragejs';
export default Model.extend({
name: '',
country: belongsTo('country', {inverse: 'cities'})
});
and the serializer:
// mirage/serializers/application.js
import { camelize, capitalize, underscore } from '#ember/string';
import { JSONAPISerializer } from 'miragejs';
export default class ApplicationSerializer extends JSONAPISerializer
{
alwaysIncludeLinkageData = true;
keyForAttribute(attr) {
return underscore(attr);
};
keyForRelationship(modelName) {
return underscore(modelName);
};
typeKeyForModel(model) {
return capitalize(camelize(model.modelName));
};
};
When I run the tests:
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Mirage | mirage models', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it retrieves the country', async function (assert) {
const server = this.server;
let city = server.create('city', { id: '1', name: 'Paris' });
server.create(
'country',
{
id: 'FR',
name: 'France',
iso3166_1_alpha3: 'FRA',
capitol_city: city
}
);
let response = await fetch('/api/countries')
assert.strictEqual(response.status, 200, "Should have created the model");
let json = await response.json();
assert.deepEqual(
json,
{
data: [
{
type: 'Country',
id: 'FR',
attributes: {
name: 'France',
iso3166_1_alpha3: 'FRA',
},
relationships: {
capitol_city: {data: {type: 'City', id: '1'}},
cities: {data: []},
}
}
]
}
)
});
test('it creates the country', async function (assert) {
const server = this.server;
server.create('city', { id: '1', name: 'Paris' });
let response = await fetch(
'/api/countries',
{
method: 'POST',
headers: {'Countent-Type': 'application/json'},
body: JSON.stringify(
{
data: {
id: 'FR',
type: 'Country',
attributes: {
iso3166_1_alpha3: 'FRA',
name: 'France',
},
relationships: {
capitol_city: { data: { type: 'City', id: '1'} },
cities: { data: [{ type: 'City', id: '1'}] }
}
}
}
)
}
);
console.log((await response.json()).message);
assert.strictEqual(response.status, 201, "Should have created the model");
});
});
The first one passes and the second one fails with the message:
Mirage: You're passing the relationship 'capitol_city' to the 'country' model via a POST to '/api/countries', but you did not define the 'capitol_city' association on the 'country' model.
How can I get Mirage to recognise the capitol_city attribute on the model?
Mirage is opinionated with regards to the format of attributes and expects the attributes to be in camelCase (and not snake_case).
Unfortunately the Ember CLI Mirage model relationships documentation does not mention this expectation and all the examples use single-word attributes. Even more unfortunately, Mirage will work with snake_case attributes for simple GET requests and when directly creating models through the API; it is only when you make a request to POST/PUT/PATCH a model into the server that it fails and the message will (confusingly) refer to the snake case attribute which has been defined. (See the Mirage source code for where it fails.)
To solve it, convert the attributes to camel case:
// mirage/models/country.js
import { Model, belongsTo, hasMany } from 'miragejs';
export default Model.extend({
name: '',
iso31661Alpha3: 0,
capitolCity: belongsTo('city', {inverse: null}),
cities: hasMany('city', {inverse: 'country'})
});
and change it in the tests as well:
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Mirage | mirage models', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it retrieves the country', async function (assert) {
const server = (this as any).server;
let city = server.create('city', { id: '1', name: 'Paris' });
server.create(
'country',
{
id: 'FR',
name: 'France',
iso31661Alpha3: 'FRA',
capitolCity: city
}
);
let response = await fetch('/api/countries')
assert.strictEqual(response.status, 200, "Should have created the model");
let json = await response.json();
console.log(JSON.stringify(json));
assert.deepEqual(
json,
{
data: [
{
type: 'Country',
id: 'FR',
attributes: {
name: 'France',
iso3166_1_alpha3: 'FRA',
},
relationships: {
capitol_city: {data: {type: 'City', id: '1'}},
cities: {data: []},
}
}
]
}
)
});
test('it creates the country', async function (assert) {
const server = (this as any).server;
let city = server.create('city', { id: '1', name: 'Paris' });
let response = await fetch(
'/api/countries',
{
method: 'POST',
headers: {'Countent-Type': 'application/json'},
body: JSON.stringify(
{
data: {
id: 'FR',
type: 'Country',
attributes: {
iso3166_1_alpha3: 'FRA',
name: 'France',
},
relationships: {
capitol_city: { data: { type: 'City', id: '1'} },
cities: { data: [{ type: 'City', id: '1'}] }
}
}
}
)
}
);
console.log((await response.json()).message);
assert.strictEqual(response.status, 201, "Should have created the model");
});
});
However, once you convert it to camel case then the attribute iso31661Alpha3 does not get formatted correctly in the output so you have to manually change the serializer for the country model:
// mirage/serializers/country.js
import ApplicationSerializer from './application';
export default class CountrySerializer extends ApplicationSerializer
{
keyForAttribute(attr: string) {
switch(attr)
{
case 'iso31661Alpha3': return 'iso3166_1_alpha3';
default: return super.keyForAttribute(attr);
}
};
};
Once the attributes are in the correct case then it will work.

Ember sideload data not linked

I'm new to using Ember and was assigned to an ongoing project and need to resolve the following:
export default class OrderModel extends Model.extend(LoadableModel) {
#attr('string') status;
#attr('number') total;
#hasMany('order-item', { async: true }) orderItems;
}
export default class OrderItemModel extends Model.extend(LoadableModel) {
#attr('number', { defaultValue: 0 }) discount;
#attr('number', { defaultValue: 0 }) price;
#hasMany('item-fix', { async: false }) fixes;
}
export default class ItemFixModel extends Model.extend(LoadableModel) {
#attr('number', { defaultValue: 0 }) price;
}
and when I do let order = await this.store.findRecord('order', order_id, { reload: true });
the json response is:
data: {
type: "orders",
id: "1584",
attributes: {
status: "in_progress",
total: 1300
},
relationships: {
order-items: {
data: [
{
type: "order-items",
id: "1801
}
]
}
}
},
included: [
{
type: "order-items"
id: "1801",
attributes: {
discount: 0,
price: 1200
},
relationships: {
item-fixes: {
data: [
{
type: "item-fixes",
id: "335"
}
]
}
},
{
type: "item-fixes",
id: "335",
attributes: {
price: 100
}
}
]
but when I inspect the orderItem inside the order variable, the itemFixes are empty, but the is in the sideload of the response.
¿How can I link this nested relationship?
Also, here is the serializer.
export default DS.JSONAPISerializer.extend({
serialize(snapshot) {
let serialized = this._super(...arguments);
let { adapterOptions } = snapshot;
if (adapterOptions && adapterOptions.addPromotionCode) {
return { code: serialized.data.attributes.code }
}
serialized.included = A([]);
snapshot.eachRelationship((key, relationship) => {
if (relationship.kind === 'belongsTo') {
if (!isBlank(snapshot.belongsTo(key))) {
let node = snapshot.belongsTo(key).record.serialize({ includeId: true }).data;
delete node.relationships;
serialized.included.pushObject(node);
}
} else if (relationship.kind === 'hasMany') {
if (!isBlank(snapshot.hasMany(key))) {
snapshot.hasMany(key).forEach(ele => {
let node = ele.record.serialize({ includeId: true }).data;
delete node.relationships;
serialized.included.pushObject(node);
});
}
}
});
return serialized;
}
});
In your response the relationship name is item-fixes but in your model it's just fixes. It must be the same.

Verify account endpoint in loopback

I am trying to implement verify account endpoint in loopback4 thorugh mailgun, since I am new in loopback4 and typescript in general, I am not sure if I am doing the right way. I want to retype the following code in the picture for loopback.I have already done with saving active flag in database and generating secretToken at signing up.
My code in loopback
#post('/users/verify')
async verify(
#requestBody(VerifyRequestBody) userData: User
): Promise<{ userData: object }> {
try {
const foundUser = await this.userRepository.findOne({
where: {
secretToken: userData.secretToken,
}
})
if (!foundUser) {
throw new HttpErrors.Forbidden(`No user found`)
}
foundUser.active = true;
foundUser.secretToken = ''
const savedUser = await this.userRepository.create(foundUser);
} catch (err) {
return err
}
}
User model, I am using MongoDB
import { Entity, model, property } from '#loopback/repository';
#model({ settings: {} })
export class User extends Entity {
#property({
type: 'string',
id: true,
})
id: string;
#property({
type: 'string',
required: true,
})
email: string;
#property({
type: 'string',
required: true,
})
password: string;
#property({
type: 'string',
required: true,
})
firstName: string;
#property({
type: 'string',
required: true,
})
lastName: string;
#property({
type: 'string',
required: false
})
secretToken: string;
#property({
type: 'boolean',
required: false,
default: false
})
active: boolean;
#property.array(String)
permissions: String[]
constructor(data?: Partial<User>) {
super(data);
}
}
export interface UserRelations {
// describe navigational properties here
}
export type UserWithRelations = User & UserRelations;
User repository
import { DefaultCrudRepository } from '#loopback/repository';
import { User, UserRelations } from '../models';
import { MongoDsDataSource } from '../datasources';
import { inject } from '#loopback/core';
export type Credentials = {
email: string,
password: string,
active: boolean
}
export type Verify = {
secretToken: string
}
export class UserRepository extends DefaultCrudRepository<
User,
typeof User.prototype.id,
UserRelations
> {
constructor(#inject('datasources.mongoDS') dataSource: MongoDsDataSource) {
super(User, dataSource);
}
}

Loopback 4 REST Controller path returns NotFoundError 404

Created a REST Controller with CRUD functions object via the CLI using
lb4 controller media
pointing to an existing MediaRepository for an existing Entity Media model
both of which were generated using the lb4 CLI as well.
A MediaController class was created with all of the REST routes for /media*
The /ping route works fine so I looked for any special routing configuration for it to see if there might be a config messing for /media but nothing was obvious.
An HTTP Get request to /media response with a web page having the following content:
<h1>NotFoundError</h1>
<h2><em>404</em> Endpoint "GET /media" not found.</h2>
There is probably some fundamental configuration or setup that needs to happen but I am just not seeing it.
MediaController class
import {
Count,
CountSchema,
Filter,
repository,
Where,
} from '#loopback/repository';
import {
post,
param,
get,
getFilterSchemaFor,
getWhereSchemaFor,
patch,
put,
del,
requestBody, Request, RestBindings, ResponseObject
} from '#loopback/rest';
import { Media } from '../models';
import { MediaRepository } from '../repositories';
export class MediaController {
constructor(
#repository(MediaRepository)
public mediaRepository: MediaRepository,
) { }
#post('/media', {
responses: {
'200': {
description: 'Media model instance',
content: { 'application/json': { schema: { 'x-ts-type': Media } } },
},
},
})
async create(#requestBody() media: Media): Promise<Media> {
return await this.mediaRepository.create(media);
}
#get('/media/count', {
responses: {
'200': {
description: 'Media model count',
content: { 'application/json': { schema: CountSchema } },
},
},
})
async count(
#param.query.object('where', getWhereSchemaFor(Media)) where?: Where<Media>,
): Promise<Count> {
return await this.mediaRepository.count();
}
#get('/media', {
responses: {
'200': {
description: 'Array of Media model instances',
content: {
'application/json': {
schema: { type: 'array', items: { 'x-ts-type': Media } },
},
},
},
},
})
async find(
#param.query.object('filter', getFilterSchemaFor(Media)) filter?: Filter<Media>,
): Promise<Media[]> {
return await this.mediaRepository.find(filter);
}
#patch('/media', {
responses: {
'200': {
description: 'Media PATCH success count',
content: { 'application/json': { schema: CountSchema } },
},
},
})
async updateAll(
#requestBody() media: Media,
#param.query.object('where', getWhereSchemaFor(Media)) where?: Where<Media>,
): Promise<Count> {
return await this.mediaRepository.updateAll(media, where);
}
#get('/media/{id}', {
responses: {
'200': {
description: 'Media model instance',
content: { 'application/json': { schema: { 'x-ts-type': Media } } },
},
},
})
async findById(#param.path.string('id') id: string): Promise<Media> {
return await this.mediaRepository.findById(id);
}
#patch('/media/{id}', {
responses: {
'204': {
description: 'Media PATCH success',
},
},
})
async updateById(
#param.path.string('id') id: string,
#requestBody() media: Media,
): Promise<void> {
await this.mediaRepository.updateById(id, media);
}
#put('/media/{id}', {
responses: {
'204': {
description: 'Media PUT success',
},
},
})
async replaceById(
#param.path.string('id') id: string,
#requestBody() media: Media,
): Promise<void> {
await this.mediaRepository.replaceById(id, media);
}
#del('/media/{id}', {
responses: {
'204': {
description: 'Media DELETE success',
},
},
})
async deleteById(#param.path.string('id') id: string): Promise<void> {
await this.mediaRepository.deleteById(id);
}
}
So I set lb4 aside for a while while I evaluated other frameworks.
Came back to my lb4 demo project today. No changes to anything since then. Started the application.
npm run start
Browsed to localhost:3000/media
To my surprise it returned a json response. Now my response array was empty and it should have returned something as there were documents in the mongodb datasource but that is a separate issue to figure out.

get from model and then set a new property on it

I have a component:
App.MyChildComponent = Ember.Component.extend({
addTooltips: Ember.on('didInsertElement', function() {
var me = this;
var metrics = this.get('option.metrics');
metrics.forEach(function(e, i) {
me.get('option.metrics').objectAt(i - 1).set('tooltipDisabled', true);
});
});
})
Which is generated inside an each loop by a different component:
App.MyParentComponent = Ember.Component.extend({
...
})
And the template of MyParentComponent is:
{{#each option in options}}
{{my-child option=option}}
{{/each}}
All this, is called by a view with a template like this:
{{my-parent options=options}}
options is defined in the model of the view with:
App.MyViewModel = Ember.Object.extend({
options: Ember.A([
{ metrics: Ember.A([
{ name: 'a' },
{ name: 'b' },
{ name: 'c' }
]) },
{ metrics: Ember.A([
{ name: 'd' },
{ name: 'e' },
{ name: 'f' }
]) },
{ metrics: Ember.A([
{ name: 'g' },
{ name: 'h' },
{ name: 'i' }
]) }
]),
});
When I run me.get('option.metrics').objectAt(i - 1).set('tooltipDisabled', true); I get:
Uncaught TypeError: me.get(...).objectAt(...).set is not a function
What am I doing wrong?
Vanilla JavaScript objects don't have set methods. Use Ember.Objects instead:
App.MyViewModel = Ember.Object.extend({
options: Ember.A([
{ metrics: Ember.A([
Ember.Object.create({ name: 'a' }),
// ...
]) }
]),
});
Demo.