Skip to content

Commit

Permalink
fix: more type safety
Browse files Browse the repository at this point in the history
  • Loading branch information
mike-north committed Nov 1, 2018
1 parent 89e596a commit 8a8df37
Show file tree
Hide file tree
Showing 16 changed files with 315 additions and 104 deletions.
2 changes: 1 addition & 1 deletion addon/index.ts
@@ -1,5 +1,5 @@
import memberAction from './utils/member-action';
import collectionAction from './utils/collection-action';
import memberAction from './utils/member-action';
import serializeAndPush from './utils/serialize-and-push';

export const classOp = collectionAction;
Expand Down
47 changes: 26 additions & 21 deletions addon/utils/build-url.ts
@@ -1,23 +1,21 @@
/* eslint-disable no-unused-vars */
import Model from 'ember-data/model';
import Store from 'ember-data/store';
import { getOwner } from '@ember/application';
import EngineInstance from '@ember/engine/instance';
import DS from 'ember-data';
import Model from 'ember-data/model';
import { EmberDataRequestType } from './types';

/**
* Given a record, obtain the ember-data model class
* @param {Model} record
* @return {typeof Model}
* @param record
*/
export function _getModelClass(record) {
return /** @type {typeof Model} */ (record.constructor);
export function _getModelClass<M extends typeof Model>(record: InstanceType<M>): M {
return record.constructor as M;
}

/**
* Given an ember-data model class, obtain its name
* @param {typeof Model} clazz
* @returns {string}
* @param clazz
*/
export function _getModelName(clazz) {
export function _getModelName(clazz: typeof Model): string {
return (
// prettier-ignore
clazz.modelName // modern use
Expand All @@ -28,29 +26,36 @@ export function _getModelName(clazz) {

/**
* Given an ember-data-record, obtain the related Store
* @param {Model} record
* @return {Store}
* @param record
*/
export function _getStoreFromRecord(record) {
/** @type {EngineInstance} */
export function _getStoreFromRecord(record: Model) {
const owner = getOwner(record);
return owner.lookup('service:store');
}

function snapshotFromRecord(model: Model): DS.Snapshot {
return (model as any)._createSnapshot();
}

/**
*
* @param {Model} record
* @param {string} opPath
* @param {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'} urlType
* @param {boolean} [instance]
* @param record
* @param opPath
* @param urlType
* @param instance
*/
export function buildOperationUrl(record, opPath, urlType, instance = true) {
export function buildOperationUrl<M extends Model>(
record: M,
opPath: string,
urlType: EmberDataRequestType,
instance = true
) {
const modelClass = _getModelClass(record);
const modelName = _getModelName(modelClass);
const store = _getStoreFromRecord(record);
const adapter = store.adapterFor(modelName);
const path = opPath;
const snapshot = record._createSnapshot();
const snapshot = snapshotFromRecord(record);
const baseUrl = adapter.buildURL(modelName, instance ? record.get('id') : null, snapshot, urlType);

if (!path) {
Expand Down
36 changes: 25 additions & 11 deletions addon/utils/collection-action.ts
@@ -1,22 +1,36 @@
import { merge } from '@ember/polyfills';
import { buildOperationUrl, _getStoreFromRecord, _getModelName, _getModelClass } from './build-url';
import Model from 'ember-data/model';
import { Value as JSONValue } from 'json-typescript';
import { _getModelClass, _getModelName, _getStoreFromRecord, buildOperationUrl } from './build-url';
import { EmberDataRequestType, Hook, HTTPVerb, strictifyHttpVerb } from './types';

export default function instanceOp(options) {
return function(payload) {
export interface CollectionOperationOptions<IN, OUT> {
type?: HTTPVerb;
path: string;
urlType?: EmberDataRequestType;
ajaxOptions?: any;
before?: Hook<IN, any>;
after?: Hook<any, OUT>;
}

export default function collectionOp<IN = any, OUT = any>(options: CollectionOperationOptions<IN, OUT>) {
return function runCollectionOp(this: Model, payload: IN): Promise<OUT> {
const recordClass = _getModelClass(this);
const modelName = _getModelName(recordClass);
const store = _getStoreFromRecord(this);
const requestType = (options.type || 'PUT').toUpperCase();
const urlType = options.urlType || requestType;
const requestType: HTTPVerb = strictifyHttpVerb(options.type || 'put');
const urlType: EmberDataRequestType = options.urlType || 'updateRecord';
const adapter = store.adapterFor(modelName);
const fullUrl = buildOperationUrl(this, options.path, urlType, false);
const data = (options.before && options.before.call(this, payload)) || payload;
return adapter.ajax(fullUrl, requestType, merge(options.ajaxOptions || {}, { data })).then(response => {
if (options.after && !this.isDestroyed) {
return options.after.call(this, response);
}
return adapter
.ajax(fullUrl, requestType, merge(options.ajaxOptions || {}, { data }))
.then((response: JSONValue) => {
if (options.after && !this.isDestroyed) {
return options.after.call(this, response);
}

return response;
});
return response;
});
};
}
32 changes: 22 additions & 10 deletions addon/utils/member-action.ts
@@ -1,19 +1,31 @@
import { merge } from '@ember/polyfills';
import { buildOperationUrl, _getModelClass, _getModelName, _getStoreFromRecord } from './build-url';
import Model from 'ember-data/model';
import { Value as JSONValue } from 'json-typescript';
import { _getModelClass, _getModelName, _getStoreFromRecord, buildOperationUrl } from './build-url';
import { EmberDataRequestType, Hook, HTTPVerb, strictifyHttpVerb } from './types';

export default function instanceOp(options) {
return function(payload) {
export interface InstanceOperationOptions<IN, OUT> {
type?: HTTPVerb;
path: string;
urlType?: EmberDataRequestType;
ajaxOptions?: any;
before?: Hook<IN, any>;
after?: Hook<any, OUT>;
}

export default function instanceOp<IN = any, OUT = any>(options: InstanceOperationOptions<IN, OUT>) {
return function runInstanceOp(this: Model, payload: IN): Promise<OUT> {
const recordClass = _getModelClass(this);
const modelName = _getModelName(recordClass);
const store = _getStoreFromRecord(this);
const requestType = (options.type || 'PUT').toUpperCase();
const urlType = options.urlType || requestType;
const { ajaxOptions, path, before, after, type = 'put', urlType = 'updateRecord' } = options;
const requestType: HTTPVerb = strictifyHttpVerb(type);
const adapter = store.adapterFor(modelName);
const fullUrl = buildOperationUrl(this, options.path, urlType);
const data = (options.before && options.before.call(this, payload)) || payload;
return adapter.ajax(fullUrl, requestType, merge(options.ajaxOptions || {}, { data })).then(response => {
if (options.after && !this.isDestroyed) {
return options.after.call(this, response);
const fullUrl = buildOperationUrl(this, path, urlType);
const data = (before && before.call(this, payload)) || payload;
return adapter.ajax(fullUrl, requestType, merge(ajaxOptions || {}, { data })).then((response: JSONValue) => {
if (after && !this.isDestroyed) {
return after.call(this, response);
}

return response;
Expand Down
46 changes: 36 additions & 10 deletions addon/utils/serialize-and-push.ts
@@ -1,19 +1,45 @@
import { isArray } from '@ember/array';
import { typeOf } from '@ember/utils';
import Model from 'ember-data/model';
import JSONSerializer from 'ember-data/serializers/json';
import SerializerRegistry from 'ember-data/types/registries/serializer';
import { CollectionResourceDoc, Document as JSONApiDoc, DocWithData, SingleResourceDoc } from 'jsonapi-typescript';
import { _getModelClass, _getModelName, _getStoreFromRecord } from './build-url';

export default function serializeAndPush(response) {
const isJsonApi = response.jsonapi && response.jsonapi.version;
if (!isJsonApi) {
// eslint-disable-next-line no-console
console.warn('serializeAndPush may only be used with a JSON API document. Ignoring response. Document must have a mandatory JSON API object. See https://jsonapi.org/format/#document-jsonapi-object.');
function isJsonApi(raw: any): raw is JSONApiDoc {
return raw.jsonapi && raw.jsonapi.version;
}
function isDocWithData(doc: any): doc is DocWithData {
return isJsonApi(doc) && ['object', 'array'].indexOf(typeOf((doc as DocWithData).data)) >= 0;
}

export default function serializeAndPush(this: Model, response: any) {
if (!isDocWithData(response)) {
// tslint:disable-next-line:no-console
console.warn(
'serializeAndPush may only be used with a JSON API document. Ignoring response. ' +
'Document must have a mandatory JSON API object. See https://jsonapi.org/format/#document-jsonapi-object.'
);
return response;
}

const recordClass = _getModelClass(this);
const modelName = _getModelName(recordClass);
const store = _getStoreFromRecord(this);
const serializer = store.serializerFor(modelName);
const normalized = isArray(response.data) ? serializer.normalizeArrayResponse(store, recordClass, response) :
serializer.normalizeSingleResponse(store, recordClass, response);
return this.store.push(normalized);
const serializer: JSONSerializer = store.serializerFor(modelName as keyof SerializerRegistry);
let normalized: {};
if (isArray(response.data)) {
const doc = response as CollectionResourceDoc;
normalized = serializer.normalizeArrayResponse(store, recordClass as any, doc, null as any, 'findAll');
} else {
const doc = response as SingleResourceDoc;
normalized = serializer.normalizeSingleResponse(
store,
recordClass as any,
doc,
`${doc.data.id || '(unknown)'}`,
'findRecord'
);
}
return store.push(normalized);
}
53 changes: 53 additions & 0 deletions addon/utils/types.ts
@@ -0,0 +1,53 @@
import Model from 'ember-data/model';

export type StrictHTTPVerb = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
export type HTTPVerb =
| 'GET'
| 'POST'
| 'PUT'
| 'DELETE'
| 'PATCH'
| 'OPTIONS'
| 'HEAD'
| 'get'
| 'post'
| 'put'
| 'delete'
| 'patch'
| 'options'
| 'head';

export interface HTTPVerbStrictMap {
GET: 'GET';
POST: 'POST';
PUT: 'PUT';
DELETE: 'DELETE';
PATCH: 'PATCH';
OPTIONS: 'OPTIONS';
HEAD: 'HEAD';
get: 'GET';
post: 'POST';
put: 'PUT';
delete: 'DELETE';
patch: 'PATCH';
options: 'OPTIONS';
head: 'HEAD';
}

export function strictifyHttpVerb<K extends keyof HTTPVerbStrictMap>(notStrict: K): HTTPVerbStrictMap[K] {
return `${notStrict}`.toUpperCase() as HTTPVerbStrictMap[K];
}

export type EmberDataRequestType =
| 'findRecord'
| 'findAll'
| 'query'
| 'queryRecord'
| 'findMany'
| 'findHasMany'
| 'findBelongsTo'
| 'createRecord'
| 'updateRecord'
| 'deleteRecord';

export type Hook<IN, OUT> = (this: Model, payload: IN) => OUT;
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -43,6 +43,7 @@
"ember-cli-qunit": "^4.4.0",
"ember-cli-shims": "^1.1.0",
"ember-cli-test-loader": "^2.2.0",
"ember-cli-tslint": "^0.1.4",
"ember-cli-typescript-blueprints": "^2.0.0-beta.1",
"ember-cli-uglify": "^2.0.0",
"ember-code-snippet": "^2.1.0",
Expand All @@ -60,6 +61,7 @@
"loader.js": "^4.7.0",
"qunit-dom": "^0.8.0",
"travis-deploy-once": "^5.0.7",
"tslint-config-prettier": "^1.15.0",
"typescript": "^3.1.5"
},
"optionalDependencies": {
Expand Down
36 changes: 18 additions & 18 deletions tests/acceptance/index-test.ts
@@ -1,43 +1,43 @@
import { module, test } from 'qunit';
import { visit, click } from '@ember/test-helpers';
import { click, visit } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import Pretender from 'pretender';
import { module, test } from 'qunit';

module('Acceptance | index2', function(hooks) {
module('Acceptance | index2', hooks => {
setupApplicationTest(hooks);
let server;
let server: any;
hooks.beforeEach(() => {
server = new Pretender();
});
hooks.afterEach(() => {
server.shutdown();
});

test('visiting /', async function(assert) {
test('visiting /', async assert => {
await visit('/');
assert.expect(8);

server.put('/fruits/:id/doRipen', request => {
let data = JSON.parse(request.requestBody);
server.put('/fruits/:id/doRipen', (request: { url: string; requestBody: string }) => {
const data = JSON.parse(request.requestBody);
assert.deepEqual(data, { id: '1', name: 'apple' }, 'member action - request payload is correct');
assert.equal(request.url, '/fruits/1/doRipen', 'request was made to "doRipen"');
return [200, {}, '{"status": "ok"}'];
});

server.put('/fruits/ripenEverything', request => {
let data = JSON.parse(request.requestBody);
server.put('/fruits/ripenEverything', (request: { url: string; requestBody: string }) => {
const data = JSON.parse(request.requestBody);
assert.deepEqual(data, { test: 'ok' }, 'collection action - request payload is correct');
assert.ok(true, 'request was made to "ripenEverything"');
return [200, {}, '{"status": "ok"}'];
});

server.get('/fruits/:id/info', request => {
server.get('/fruits/:id/info', (request: { url: string; requestBody: string }) => {
assert.equal(request.url, '/fruits/1/info?fruitId=1');
assert.ok(true, 'request was made to "ripenEverything"');
return [200, {}, '{"status": "ok"}'];
});

server.get('/fruits/fresh', request => {
server.get('/fruits/fresh', (request: { url: string; requestBody: string }) => {
assert.equal(request.url, '/fruits/fresh?month=July');
assert.ok(true, 'request was made to "ripenEverything"');
return [200, {}, '{"status": "ok"}'];
Expand All @@ -52,14 +52,14 @@ module('Acceptance | index2', function(hooks) {
await click('.all-fruit .fresh-type-button');
});

test('before/after hooks and serializeAndPush helper', async function(assert) {
test('before/after hooks and serializeAndPush helper', async assert => {
await visit('/');
assert.expect(7);

server.put('/fruits/:id/doEat', request => {
let data = JSON.parse(request.requestBody);
server.put('/fruits/:id/doEat', (request: { url: string; requestBody: string }) => {
const data = JSON.parse(request.requestBody);

let expectedData = {
const expectedData = {
data: {
type: 'fruits',
attributes: {
Expand All @@ -84,10 +84,10 @@ module('Acceptance | index2', function(hooks) {
return [200, {}, JSON.stringify(response)];
});

server.put('/fruits/doEatAll', request => {
let data = JSON.parse(request.requestBody);
server.put('/fruits/doEatAll', (request: { url: string; requestBody: string }) => {
const data = JSON.parse(request.requestBody);

let expectedData = {
const expectedData = {
data: {
type: 'fruits',
attributes: {
Expand Down

0 comments on commit 8a8df37

Please sign in to comment.