Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:

strategy:
matrix:
node-version: ['6']
node-version: ['14']

steps:
- name: Checkout code
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"moment": "^2.22.0"
},
"jest": {
"testEnvironment": "node",
"testEnvironment": "jsdom",
"roots": [
"./src"
],
Expand Down
44 changes: 42 additions & 2 deletions src/BinderApi.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { get } from 'lodash';
import { get, range } from 'lodash';
import axios from 'axios';

// Function ripped from Django docs.
Expand All @@ -8,6 +8,30 @@ function csrfSafeMethod(method) {
return /^(GET|HEAD|OPTIONS|TRACE)$/i.test(method);
}

function escapeKey(key) {
return key.toString().replace(/([.\\])/g, '\\$1');
}

function extractFiles(data, prefix = '') {
const keys = (
Array.isArray(data)
? range(data.length)
: typeof data === 'object' && data !== null
? Object.keys(data)
: []
);
let files = {};
for (const key of keys) {
if (data[key] instanceof Blob) {
files[prefix + escapeKey(key)] = data[key];
data[key] = null;
} else if (typeof data[key] === 'object' && data[key] !== null) {
Object.assign(files, extractFiles(data[key], prefix + escapeKey(key) + '.'));
}
}
return files;
}

export default class BinderApi {
baseUrl = null;
csrfToken = null;
Expand Down Expand Up @@ -63,10 +87,26 @@ export default class BinderApi {
'X-Csrftoken': useCsrfToken,
},
this.defaultHeaders,
options.headers
options.headers,
);
axiosOptions.headers = headers;

if (
axiosOptions.data &&
!(axiosOptions.data instanceof Blob) &&
!(axiosOptions.data instanceof FormData)
) {
const files = extractFiles(axiosOptions.data);
if (Object.keys(files).length > 0) {
const data = new FormData();
data.append('data', JSON.stringify(axiosOptions.data));
for (const [path, file] of Object.entries(files)) {
data.append('file:' + path, file, file.name);
}
axiosOptions.data = data;
}
}

const xhr = this.axios(axiosOptions);

// We fork the promise tree as we want to have the error traverse to the listeners
Expand Down
2 changes: 0 additions & 2 deletions src/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
mapValues,
find,
filter,
get,
isPlainObject,
isArray,
omit,
Expand All @@ -23,7 +22,6 @@ import {
uniqBy,
mapKeys,
result,
pick,
} from 'lodash';
import Store from './Store';
import { invariant, snakeToCamel, camelToSnake, relationsToNestedKeys, forNestedRelations } from './utils';
Expand Down
80 changes: 80 additions & 0 deletions src/__tests__/BinderApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,83 @@ test('Failing request with onRequestError and skipRequestError option', () => {
expect(api.onRequestError).not.toHaveBeenCalled();
});
});

test('Blobs in json get converted to form data', () => {
const foo = new Blob(['foo'], { type: 'text/plain' });
const bar = new Blob(['bar'], { type: 'text/plain' });

mock.onAny().replyOnce(config => {
expect(config.url).toBe('/api/test/');
expect(config.method).toBe('put');
expect(config.params).toEqual(undefined);
expect(config.data).toBeInstanceOf(FormData);

const keys = Array.from(config.data.keys()).sort();
expect(keys).toEqual(['data', 'file:bar.2', 'file:foo']);

const data = JSON.parse(config.data.get('data'));
expect(data).toEqual({
foo: null,
bar: [1, 'test', null],
});

const foo = config.data.get('file:foo');
expect(foo).toBeInstanceOf(Blob);

const bar = config.data.get('file:bar.2');
expect(bar).toBeInstanceOf(Blob);

return [200, {}];
});

const api = new BinderApi();
return api.put('/api/test/', {
foo,
bar: [1, 'test', bar],
});
});

test('FormData is left intact', () => {
const foo = new Blob(['foo'], { type: 'text/plain' });
const bar = new Blob(['bar'], { type: 'text/plain' });
const data = new FormData();
data.set('foo', foo);
data.set('bar', bar);

mock.onAny().replyOnce(config => {
expect(config.url).toBe('/api/test/');
expect(config.method).toBe('put');
expect(config.params).toEqual(undefined);
expect(config.data).toBeInstanceOf(FormData);

const keys = Array.from(config.data.keys()).sort();
expect(keys).toEqual(['bar', 'foo']);

const foo = config.data.get('foo');
expect(foo).toBeInstanceOf(Blob);

const bar = config.data.get('bar');
expect(bar).toBeInstanceOf(Blob);

return [200, {}];
});

const api = new BinderApi();
return api.put('/api/test/', data);
});

test('Blob is left intact', () => {
const data = new Blob(['foo'], { type: 'text/plain' });

mock.onAny().replyOnce(config => {
expect(config.url).toBe('/api/test/');
expect(config.method).toBe('put');
expect(config.params).toEqual(undefined);
expect(config.data).toBeInstanceOf(Blob);

return [200, {}];
});

const api = new BinderApi();
return api.put('/api/test/', data);
});
74 changes: 70 additions & 4 deletions src/__tests__/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import axios from 'axios';
import { toJS, observable } from 'mobx';
import MockAdapter from 'axios-mock-adapter';
import _ from 'lodash';
import { Model, BinderApi, Casts } from '../';
import { Model, BinderApi } from '../';
import {
Animal,
AnimalStore,
Expand All @@ -19,6 +19,8 @@ import {
Person,
PersonStore,
Location,
File,
FileCabinet,
} from './fixtures/Animal';
import { Customer, Location as CLocation } from './fixtures/Customer';
import animalKindBreedData from './fixtures/animal-with-kind-breed.json';
Expand Down Expand Up @@ -516,9 +518,6 @@ test('toBackend with omit fields', () => {

const serialized = model.toBackend();

const expected = {
weight: 32
}
expect(serialized).toEqual({
color: 'red',
id: 1
Expand Down Expand Up @@ -1026,6 +1025,73 @@ describe('requests', () => {
});
});

test('Save model with file', () => {
const file = new File({ id: 5 });
const dataFile = new Blob(['foo'], { type: 'text/plain' });
file.setInput('dataFile', dataFile);

mock.onAny().replyOnce(config => {
expect(config.method).toBe('patch');

expect(config.data).toBeInstanceOf(FormData);

const keys = Array.from(config.data.keys()).sort();
expect(keys).toEqual(['data', 'file:data_file']);

const data = JSON.parse(config.data.get('data'));
expect(data).toEqual({
data_file: null,
id: 5,
});
return [200, { id: 5, data_file: '/api/dataFile' } ];
});

file.save().then(() => {
expect(file.id).toBe(5);
expect(file.dataFile).toBe('/api/dataFile');
});
});

test('Save model with relations and multiple files', () => {
const fileCabinet = new FileCabinet({ id: 5 },{relations: ['files']});
fileCabinet.files.add([
{ dataFile: new Blob(['bar'], { type: 'text/plain' }) },
{ dataFile: new Blob(['foo'], { type: 'text/plain' }) },
{ dataFile: new Blob(['baz'], { type: 'text/plain' }) },
]);

mock.onAny().replyOnce(config => {
expect(config.method).toBe('put');

expect(config.data).toBeInstanceOf(FormData);

const keys = Array.from(config.data.keys()).sort();
expect(keys).toEqual([
'data',
'file:with.files.0.data_file',
'file:with.files.1.data_file',
'file:with.files.2.data_file']);

const data = JSON.parse(config.data.get('data'));
expect(data).toEqual({
data: [{
id: 5,
files: [-2, -3, -4],
}],
with: {
files: [
{ id: -2, data_file: null },
{ id: -3, data_file: null },
{ id: -4, data_file: null },
],
},
});
return [200, {}];
});

fileCabinet.save({ relations: ['files'] });
});

test('fetch with relations', () => {
const animal = new Animal(
{ id: 2 },
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/fixtures/Animal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ export class Location extends Model {
@observable name = '';
}

export class File extends Model {
urlRoot = '/api/file/';
api = new BinderApi();
static backendResourceName = 'file';
@observable id = null;
@observable dataFile = null;
}

export class FileStore extends Store {
Model = File
}

export class FileCabinet extends Model {
urlRoot = '/api/file_cabinet/';
api = new BinderApi();
static backendResourceName = 'file_cabinet';
@observable id = null;

relations() {
return {
files: FileStore,
}
}
}


export class Breed extends Model {
@observable id = null;
@observable name = '';
Expand Down