Skip to content

Instantly share code, notes, and snippets.

@leepfrog
Last active November 7, 2023 07:10
Show Gist options
  • Save leepfrog/cbac93bc786ea8cfc2b080008c18ed83 to your computer and use it in GitHub Desktop.
Save leepfrog/cbac93bc786ea8cfc2b080008c18ed83 to your computer and use it in GitHub Desktop.
Ember-Data JSON:API Atomic Operations Handler with mswjs & mswjs/data (WIP)
// Change 0: Add book(0)
// Change 1: Add book-category and assign to book(0)
// Change 2: Remove category-tag from book(1).category-tag(0)
// Change 3: Update title on book(1)
{
"atomic:operations": [
{
"data": {
"attributes": {
"name": "untitled"
},
"lid": "5f4ec449-440c-49c2-9f60-198913f7b554",
"relationships": {
"bookCategories": {
"data": [
{
"id": null,
"lid": "4d8043df-a1a0-41b4-aaa4-f877d619b3a2",
"type": "book-category"
}
]
}
},
"type": "book"
},
"op": "add"
},
{
"data": {
"attributes": {},
"lid": "4d8043df-a1a0-41b4-aaa4-f877d619b3a2",
"relationships": {
"book": {
"data": {
"id": null,
"lid": "5f4ec449-440c-49c2-9f60-198913f7b554",
"type": "book"
}
},
"categoryTags": {}
},
"type": "book-category"
},
"op": "add"
},
{
"op": "remove",
"ref": {
"id": "333513e9-dce9-42d5-8f39-2d1d97f8412b",
"type": "category-tag"
}
},
{
"data": {
"attributes": {
"name": "amet-quas-laboriosam!!!"
},
"id": "4f31197c-9728-4801-9d91-76988c3e3a31",
"type": "book"
},
"op": "update"
}
]
}
{
"atomic:results": [
{
"attributes": {
"lid": "",
"name": "untitled",
"createdAt": "2023-11-07T07:05:04.448Z"
},
"id": "e5095606-2119-45a8-a553-dc48aab4d554",
"links": {
"self": "http://localhost:4000/book/e5095606-2119-45a8-a553-dc48aab4d554"
},
"type": "book",
"relationships": {
"bookCategories": {
"data": []
}
}
},
{
"attributes": {
"lid": "",
"createdAt": "2023-11-07T07:05:04.448Z"
},
"id": "03a0990d-e4b7-4042-826c-f80b6b9884a1",
"links": {
"self": "http://localhost:4000/book-category/03a0990d-e4b7-4042-826c-f80b6b9884a1"
},
"type": "book-category",
"relationships": {
"book": {
"data": {
"id": "e5095606-2119-45a8-a553-dc48aab4d554",
"type": "book"
}
},
"categoryTags": {
"data": []
}
}
},
{
"data": {}
},
{
"attributes": {
"lid": "",
"name": "amet-quas-laboriosam!!!",
"createdAt": "2023-11-07T07:04:47.201Z"
},
"id": "4f31197c-9728-4801-9d91-76988c3e3a31",
"links": {
"self": "http://localhost:4000/book/4f31197c-9728-4801-9d91-76988c3e3a31"
},
"type": "book",
"relationships": {
"bookCategories": {
"data": [
{
"id": "48a1f772-c459-423e-a74d-52c2789b9c8d",
"type": "book-category"
}
]
}
}
}
]
}
const changes = [
{ op: 'add', identifier: <id1> },
{ op: 'update', identifier: <id2> },
{ op: 'remove', identifier: <id3> }
];
const req = atomicRequest(changes);
await this.store.request(req);
// Assumptions:
// Handler will serialize all changes for record when encountering add or update
// If using an add operation, an update operation should not exist for same identifier
// If using an update operation, there should only be a single update operation for the identifier
// If using a remove operation, there should not be an add/update operation (though, there might be a circumstance where you want to make a change and then delete the record...)
import type { StableRecordIdentifier } from '@ember-data/types/q/identifier';
export default interface Changeset {
op: 'add' | 'update' | 'remove';
identifier: StableRecordIdentifier;
}
// Handlers of atomic operations in MSW / mswjs/data
import db, { models } from 'my-app/mocks/db';
import { jsonApiResource } from 'my-app/mocks/jsonapi';
import { ENTITY_TYPE } from '@mswjs/data/lib/glossary';
import { camelize } from '@ember/string';
import { parseModelDefinition } from '@mswjs/data/lib/model/parseModelDefinition';
import { E, ModelKey } from 'my-app/mocks/index';
const cache: Record<string, string> = {};
function getType(type?: string): ModelKey | undefined {
if (!type) return;
type = camelize(type);
if (Object.keys(db).includes(type)) return type as unknown as ModelKey;
}
function mapResourceIdentifier(id: ResourceIdentifier) {
const type = getType(id.type);
if (!type) return;
const factory = db[type];
if (!factory) return;
let aId = id.id;
if (!aId) {
const lid = id.lid;
if (!lid) return;
aId = cache[lid];
}
if (!aId) return;
return factory.findFirst({ where: { id: { equals: aId } } });
}
function mapResourceIdentifiers(ids: ResourceIdentifier[]) {
const type = getType(ids[0]?.type);
if (!type) return [];
const factory = db[type];
if (!factory) return [];
const aIds = ids
.map((id) => {
if (id.id) return id.id;
const lid = id.lid;
if (!lid) return null;
return cache[lid] ?? null;
})
.filter((x) => x !== null) as string[];
if (aIds.length === 0) return [];
return factory.findMany({ where: { id: { in: aIds } } });
}
function mapRelationshipLinkage(rel: Relationship) {
if (!rel.data) return;
if (Array.isArray(rel.data) && rel.data.length === 0) return;
if (Array.isArray(rel.data)) return mapResourceIdentifiers(rel.data);
return mapResourceIdentifier(rel.data as ResourceIdentifier);
}
function mapRelationships(rel?: Relationships) {
if (!rel) return {};
const names = Object.keys(rel);
if (names.length === 0) return {};
const ret: Record<string, unknown> = {};
return names.reduce((acc, name) => {
acc[name] = mapRelationshipLinkage(rel[name]!) as E | E[] | undefined;
return acc;
}, ret);
}
function updateInverse(source: E, target: E) {
const sourceType = source[ENTITY_TYPE] as KeyType;
const targetType = target[ENTITY_TYPE] as KeyType;
const targetRelation = findRelationship(targetType, sourceType);
const targetKey = targetRelation?.propertyPath[0];
const targetId = target['id'] as string;
const factory = db[getType(targetType)!];
const data: Record<string, E | E[]> = {};
if (!targetKey || !factory || !targetId) return;
if (targetRelation?.relation.kind === 'MANY_OF') {
const assoc = target[targetKey]! as unknown as E[];
data[targetKey] = [...assoc, source];
} else {
data[targetKey] = source;
}
factory.update({
where: { id: { equals: targetId } },
data,
});
}
function updateRelationshipInverse(model: E, rel: string) {
const target = model[rel] as E | E[];
if (Array.isArray(target)) {
target.forEach((t) => updateInverse(model, t));
} else {
updateInverse(model, target);
}
}
function findRelationship(targetType: KeyType, sourceType: KeyType) {
const def = parseModelDefinition(
models,
targetType,
models[getType(targetType)!]!,
);
// Return the first relationship that matches
return def.relations.find(
({ relation }) => relation.target.modelName === sourceType,
);
}
export function handleOp(op: Operation) {
switch (op.op) {
case 'add':
return handleAddOp(op);
case 'update':
return handleUpdateOp(op);
case 'remove':
return handleRemoveOp(op);
}
}
export function handleAddOp(op: Operation) {
const type = getType(op.data?.type);
const lid = op.data?.lid;
let data = op.data?.attributes;
const factory = db[type!];
if (!lid && !type && !data && !factory) return { data: {} };
const rels = mapRelationships(op.data?.relationships);
data = { ...data, ...rels };
const model = factory!.create(data) as E;
cache[lid!] = model['id'] as string;
Object.keys(rels).forEach((rel) => updateRelationshipInverse(model, rel));
return jsonApiResource(model);
}
export function handleUpdateOp(op: Operation) {
const type = camelize(op.data?.type ?? '') as ModelKey | undefined;
const id = op.data?.id;
const data = op.data?.attributes;
if (!id && !type && !data) return { data: {} };
const factory = db[type!];
const model = factory?.update({ where: { id: { equals: id } }, data });
return jsonApiResource(model);
}
export function handleRemoveOp(op: Operation) {
const type = camelize(op.ref?.type ?? '') as ModelKey | undefined;
const id = op.ref?.id;
if (!id && !type) return { data: {} };
const factory = db[type!];
factory?.delete({ where: { id: { equals: id } } });
return { data: {} };
}
export default {
add: handleAddOp,
update: handleUpdateOp,
remove: handleRemoveOp,
handle: handleOp,
};
// Factory model definitions for mswjs/data
import { faker } from '@faker-js/faker';
import { factory, manyOf, nullable, oneOf, primaryKey } from '@mswjs/data';
export const models = {
book: {
id: primaryKey(faker.string.uuid),
lid: () => '',
name: faker.lorem.slug,
createdAt: () => new Date(),
bookCategories: manyOf('bookCategory'),
},
bookCategory: {
id: primaryKey(faker.string.uuid),
lid: () => '',
name: faker.lorem.slug,
createdAt: () => new Date(),
book: oneOf('book'),
categoryTags: manyOf('categoryTag'),
},
categoryTag: {
id: primaryKey(faker.string.uuid),
lid: () => '',
createdAt: () => new Date(),
bookCategory: oneOf('bookCategory')
}
};
export default factory(models);
// Handler definition for handling Atomic Operations in MSW (2.0)
import { http, HttpHandler, HttpResponse } from 'msw';
import db from './db';
import {
jsonApiIndexDocument,
jsonApiShowDocument,
jsonApiResource,
jsonApiResponseHeaders,
} from 'my-app/mocks/jsonapi';
import { getObjectsByKeypath } from 'my-app/mocks/utils';
import { handleOp } from 'my-app/mocks/atomic';
import { pluralize } from 'ember-inflector';
import { Model, ModelKey } from 'my-app/mocks/index';
const assetCache: Record<string, string> = {};
const handlers: HttpHandler[] = [
indexHandlerFor('book'),
indexHandlerFor('author'),
http.post('/atomic', async ({ request }) => {
const body = (await request.json()) as AtomicOperationRequest;
if (!body) return new HttpResponse(null, { status: 400 });
const ops = body['atomic:operations'];
if (!ops) return new HttpResponse(null, { status: 400 });
const results = ops.map(handleOp);
return HttpResponse.json({ 'atomic:results': results }, { status: 200 });
}),
];
export function indexHandlerFor<T extends ModelKey>(key: T) {
const plural = pluralize(key);
return http.get(`/${plural}`, ({ request }) => {
const url = new URL(request.url);
const data = db[key].findMany({
orderBy: { createdAt: 'desc' },
}) as Model<T>[];
const body = jsonApiIndexDocument(data, url);
const include = url.searchParams.get('include');
if (include) {
body['included'] = data.flatMap((resource) => {
return getObjectsByKeypath(resource, include)
.flat()
.map(jsonApiResource);
});
}
return HttpResponse.json(body, {
status: 200,
headers: jsonApiResponseHeaders(),
});
});
}
export function showHandlerFor<T extends ModelKey>(key: T) {
const plural = pluralize(key);
return http.get(`/${plural}/:id`, ({ request, params }) => {
let { id } = params;
id = id as string;
const url = new URL(request.url);
const data = db[key].findFirst({
where: { id: { equals: id } },
}) as Model<T>;
if (!data) return new HttpResponse(null, { status: 404 });
const body = jsonApiShowDocument(data, url);
const include = url.searchParams.get('include');
if (include) {
body['included'] = getObjectsByKeypath(data, include)
.flat()
.map(jsonApiResource);
}
return HttpResponse.json(body, {
status: 200,
headers: jsonApiResponseHeaders(),
});
});
}
export default handlers;
import { models } from 'my-app/mocks/db';
import { Entity, ModelValueType } from '@mswjs/data/lib/glossary';
declare type ModelDictionary = typeof models;
declare type ModelKey = keyof typeof models;
declare type StringIndexed = { [key: string]: ModelValueType | E | E[] };
declare type E = Entity<ModelDictionary, ModelKey> & StringIndexed;
declare type Model<T extends ModelKey> = E<ModelDictionary, T>;
// Serialize json:api objects from provided mswjs/data object
import { ENTITY_TYPE } from '@mswjs/data/lib';
import { dasherize } from '@ember/-internals/string';
import { findGetters } from 'my-app/mocks/utils';
import { singularize } from 'ember-inflector';
import { E, Model, ModelKey } from 'my-app/mocks/index';
export function jsonApiResponseHeaders() {
return {
'content-type': 'application/vnd+api.json',
};
}
export function jsonApiIndexDocument(data: E[], url: URL): TopLevelDocument {
return {
data: data.map(jsonApiResource),
links: {
self: url.toString(),
},
};
}
export function jsonApiShowDocument(data: E, url: URL): TopLevelDocument {
return {
data: jsonApiResource(data),
links: {
self: url.toString(),
},
};
}
export function jsonApiResource<T extends ModelKey>(
record: Model<T>,
): Resource {
const { id } = record;
const type = dasherize(singularize(record[ENTITY_TYPE] as string));
const attributes = { ...record };
const relationships = findGetters(record).reduce(
(relationships: Relationships, relationshipKey) => {
const relationship = jsonApiRelationship(
record,
relationshipKey,
) as unknown as E;
if (!relationship) return relationships;
relationships[relationshipKey] = relationship as Partial<E>;
delete attributes[relationshipKey];
return relationships;
},
{},
);
delete attributes['id'];
return {
attributes,
id: id as string,
links: {
self: `http://localhost:4000/${type}/${id}`,
},
type,
relationships,
};
}
export function jsonApiRelationship<T extends ModelKey>(
data: Model<T>,
key: string,
): Relationship | undefined {
const related = data[key];
if (!related) return undefined;
if (Array.isArray(related))
return { data: related.map(jsonApiResourceLinkage) as ResourceLinkage };
return { data: jsonApiResourceLinkage(related) };
}
export function jsonApiResourceLinkage(data: E): ResourceLinkage {
const id = data['id'] as string;
const type = dasherize(singularize(String(data[ENTITY_TYPE])));
return {
id,
type,
};
}
import { E } from 'my-app/mocks/index';
import { ENTITY_TYPE } from '@mswjs/data/lib';
/**
Generate an array of objects obtained from the provided keypath.
Used for handling GET /?include=<keypath1,keypath2>
*/
export function getObjectsByKeypath(obj: E, keypath: string) {
const segments = keypath.split(',');
return segments.flatMap((keypath) =>
keypath.split('.').reduce<E[]>((models, key) => {
// First iteration of the reduce
if (models.length === 0) {
const include = obj[key] as E | E[];
if (isPrimitive(include)) return [];
if (Array.isArray(include)) return include;
return [include];
} else {
// Go through each previous model until the type isn't the same
// Assume the last object fetched is the type
let type;
for (let cursor = models.length - 1; cursor >= 0; cursor--) {
const model = models[cursor];
if (!model) break;
if (!type) type = model[ENTITY_TYPE];
if (model[ENTITY_TYPE] !== type) break;
let include = model[key] as E | E[];
if (isPrimitive(include)) break;
include = include as E | E[];
if (Array.isArray(include)) {
models = [...models, ...include];
} else {
models = [...models, include];
}
}
}
return models;
}, [] as E[]),
);
}
function isPrimitive(obj: unknown) {
return (
typeof obj === 'string' ||
typeof obj === 'number' ||
typeof obj === 'bigint' ||
typeof obj === 'boolean' ||
typeof obj === 'undefined' ||
typeof obj === 'symbol'
);
}
/**
find getter functions for the provided object
*/
export function findGetters(obj: E) {
const getters = [];
for (const prop of Object.keys(obj)) {
const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
if (descriptor && typeof descriptor.get === 'function') {
getters.push(prop);
}
}
return getters;
}
import type { ConstrainedRequestOptions } from '@ember-data/types/request';
import {
type CreateRecordUrlOptions,
buildBaseURL,
} from '@ember-data/request-utils';
import type { HTTPMethod } from '@ember-data/request/-private/types';
import Changeset from 'my-app/models/changeset';
export function atomicChanges(
changes: Changeset[],
options: ConstrainedRequestOptions = {},
) {
const urlOptions: CreateRecordUrlOptions = {
identifier: { type: 'atomic' },
op: 'createRecord',
resourcePath: 'atomic',
};
if ('host' in options) {
urlOptions.host = options.host;
}
if ('namespace' in options) {
urlOptions.namespace = options.namespace;
}
if ('resourcePath' in options) {
urlOptions.resourcePath = options.resourcePath;
}
const url = buildBaseURL(urlOptions);
const headers = new Headers();
const method: HTTPMethod = 'POST';
headers.append('Accept', 'application/vnd.api+json');
headers.append(
'Content-type',
'application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"',
);
return {
url,
method,
headers,
op: 'atomic',
data: {
record: changes,
},
};
}
import type Changeset from 'my-app/models/changeset';
import type { Cache } from '@ember-data/types/cache/cache';
import type {
Handler,
NextFn,
RequestContext,
} from '@ember-data/request/-private/types';
import type { StableRecordIdentifier } from '@ember-data/types/q/identifier';
import {
serializeResources,
serializePatch,
} from '@ember-data/json-api/request';
import { singularize } from 'ember-inflector';
export default {
async request(context: RequestContext, next: NextFn<TopLevelDocument>) {
let { request } = context;
if (request.op !== 'atomic') return next(request);
if (request.options?.['handled']) return next(request);
const { store, data } = request;
const { record } = data!;
const { cache } = store!;
const ops = {
'atomic:operations': (record as Changeset[]).map((record) =>
serializeChangeset(cache, record),
),
};
request = Object.assign({}, request, {
body: JSON.stringify(ops),
});
return next(request).then(() => {
return { data: [] };
});
},
} as Handler;
function serializeChangeset(cache: Cache, { op, identifier }: Changeset) {
if (op === 'remove') return serializeRemoveOperation(identifier);
if (op === 'add') return serializeAddOperation(cache, identifier);
if (op === 'update') return serializeUpdateOperation(cache, identifier);
}
function serializeRemoveOperation({ type, id }: StableRecordIdentifier) {
return {
op: 'remove',
ref: {
type: singularize(type),
id: id,
},
};
}
function serializeAddOperation(
cache: Cache,
identifier: StableRecordIdentifier,
) {
const serialized = serializeResources(cache, identifier);
serialized.data.type = singularize(serialized.data.type ?? '');
delete serialized.data['id'];
if (Object.keys(serialized.data['relationships'] ?? {}).length === 0)
delete serialized.data['relationships'];
return {
op: 'add',
...serialized,
};
}
function serializeUpdateOperation(
cache: Cache,
identifier: StableRecordIdentifier,
) {
const serialized = serializePatch(cache, identifier);
serialized.data.type = singularize(serialized.data.type ?? '');
delete serialized.data['lid'];
if (Object.keys(serialized.data['relationships'] ?? {}).length === 0)
delete serialized.data['relationships'];
return {
op: 'update',
...serialized,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment