Last active
November 7, 2023 07:10
-
-
Save leepfrog/cbac93bc786ea8cfc2b080008c18ed83 to your computer and use it in GitHub Desktop.
Ember-Data JSON:API Atomic Operations Handler with mswjs & mswjs/data (WIP)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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" | |
} | |
] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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" | |
} | |
] | |
} | |
} | |
} | |
] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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...) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; | |
export default interface Changeset { | |
op: 'add' | 'update' | 'remove'; | |
identifier: StableRecordIdentifier; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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