chore: API Consistency (#1304)

* chore: Addressing API inconsistencies

* lint

* add: Missing sort to groups.list
fix: Documention issues

* test: fix

* feat: Add missing shares.info endpoint

* feat: Add sorting to users.list endpoint

* fix: Incorrect pagination parameters listed on user endpoints

* users.s3Upload -> attachments.create

* chore: exportAll -> export_all
This commit is contained in:
Tom Moor
2020-06-16 20:56:17 -07:00
committed by GitHub
parent 5010b08e83
commit 0f8d503df8
20 changed files with 309 additions and 250 deletions

View File

@ -42,7 +42,7 @@ class DocumentHistory extends React.Component<Props> {
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
id: this.props.match.params.documentSlug,
documentId: this.props.match.params.documentSlug,
});
if (

View File

@ -267,7 +267,7 @@ class CollectionScene extends React.Component<Props> {
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{ collection: collection.id }}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
@ -278,7 +278,7 @@ class CollectionScene extends React.Component<Props> {
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{ collection: collection.id }}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
@ -289,7 +289,7 @@ class CollectionScene extends React.Component<Props> {
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{ collection: collection.id }}
options={{ collectionId: collection.id }}
showPublished
showPin
/>
@ -300,7 +300,7 @@ class CollectionScene extends React.Component<Props> {
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{ collection: collection.id }}
options={{ collectionId: collection.id }}
showPin
/>
</Route>

View File

@ -97,11 +97,8 @@ class DataLoader extends React.Component<Props> {
};
loadRevision = async () => {
const { documentSlug, revisionId } = this.props.match.params;
this.revision = await this.props.revisions.fetch(documentSlug, {
revisionId,
});
const { revisionId } = this.props.match.params;
this.revision = await this.props.revisions.fetch(revisionId);
};
loadDocument = async () => {

View File

@ -105,6 +105,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
}
export = () => {
return client.post('/collections.exportAll');
return client.post('/collections.export_all');
};
}

View File

@ -20,21 +20,16 @@ export default class RevisionsStore extends BaseStore<Revision> {
}
@action
fetch = async (
documentId: string,
options?: FetchOptions
): Promise<?Revision> => {
fetch = async (id: string, options?: FetchOptions): Promise<?Revision> => {
this.isFetching = true;
const id = options && options.revisionId;
if (!id) throw new Error('revisionId is required');
invariant(id, 'Id is required');
try {
const rev = this.data.get(id);
if (rev) return rev;
const res = await client.post('/documents.revision', {
id: documentId,
revisionId: id,
const res = await client.post('/revisions.info', {
id,
});
invariant(res && res.data, 'Revision not available');
this.add(res.data);
@ -54,7 +49,7 @@ export default class RevisionsStore extends BaseStore<Revision> {
this.isFetching = true;
try {
const res = await client.post('/documents.revisions', options);
const res = await client.post('/revisions.list', options);
invariant(res && res.data, 'Document revisions not available');
runInAction('RevisionsStore#fetchPage', () => {
res.data.forEach(revision => this.add(revision));

View File

@ -13,7 +13,7 @@ export const uploadFile = async (
options?: Options = { name: '' }
) => {
const name = file instanceof File ? file.name : options.name;
const response = await client.post('/users.s3Upload', {
const response = await client.post('/attachments.create', {
public: options.public,
documentId: options.documentId,
contentType: file.type,
@ -24,7 +24,7 @@ export const uploadFile = async (
invariant(response, 'Response should be available');
const data = response.data;
const asset = data.asset;
const attachment = data.attachment;
const formData = new FormData();
for (const key in data.form) {
@ -44,7 +44,7 @@ export const uploadFile = async (
body: formData,
});
return asset;
return attachment;
};
export const dataUrlToBlob = (dataURL: string) => {

View File

@ -43,7 +43,7 @@ Object {
}
`;
exports[`#collections.exportAll should require authentication 1`] = `
exports[`#collections.export_all should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",

View File

@ -1,13 +1,92 @@
// @flow
import Router from 'koa-router';
import uuid from 'uuid';
import format from 'date-fns/format';
import { Attachment, Document, Event } from '../models';
import {
makePolicy,
getSignature,
publicS3Endpoint,
makeCredential,
getSignedImageUrl,
} from '../utils/s3';
import auth from '../middlewares/authentication';
import { Attachment, Document } from '../models';
import { getSignedImageUrl } from '../utils/s3';
import { NotFoundError } from '../errors';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
const AWS_S3_ACL = process.env.AWS_S3_ACL || 'private';
router.post('attachments.create', auth(), async ctx => {
let { name, documentId, contentType, size } = ctx.body;
ctx.assertPresent(name, 'name is required');
ctx.assertPresent(contentType, 'contentType is required');
ctx.assertPresent(size, 'size is required');
const { user } = ctx.state;
const s3Key = uuid.v4();
const key = `uploads/${user.id}/${s3Key}/${name}`;
const acl =
ctx.body.public === undefined
? AWS_S3_ACL
: ctx.body.public ? 'public-read' : 'private';
const credential = makeCredential();
const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z');
const policy = makePolicy(credential, longDate, acl);
const endpoint = publicS3Endpoint();
const url = `${endpoint}/${key}`;
if (documentId) {
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, 'update', document);
}
const attachment = await Attachment.create({
key,
acl,
size,
url,
contentType,
documentId,
teamId: user.teamId,
userId: user.id,
});
await Event.create({
name: 'attachments.create',
data: { name },
teamId: user.teamId,
userId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
data: {
maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE,
uploadUrl: endpoint,
form: {
'Cache-Control': 'max-age=31557600',
'Content-Type': contentType,
acl,
key,
policy,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-credential': credential,
'x-amz-date': longDate,
'x-amz-signature': getSignature(policy),
},
attachment: {
documentId,
contentType,
name,
url: attachment.redirectUrl,
size,
},
},
};
});
router.post('attachments.redirect', auth(), async ctx => {
const { id } = ctx.body;

View File

@ -410,7 +410,7 @@ router.post('collections.export', auth(), async ctx => {
ctx.body = fs.createReadStream(filePath);
});
router.post('collections.exportAll', auth(), async ctx => {
router.post('collections.export_all', auth(), async ctx => {
const { download = false } = ctx.body;
const user = ctx.state.user;

View File

@ -176,9 +176,9 @@ describe('#collections.export', async () => {
});
});
describe('#collections.exportAll', async () => {
describe('#collections.export_all', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.exportAll');
const res = await server.post('/api/collections.export_all');
const body = await res.json();
expect(res.status).toEqual(401);
@ -187,7 +187,7 @@ describe('#collections.exportAll', async () => {
it('should require authorization', async () => {
const user = await buildUser();
const res = await server.post('/api/collections.exportAll', {
const res = await server.post('/api/collections.export_all', {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(403);
@ -195,7 +195,7 @@ describe('#collections.exportAll', async () => {
it('should return success', async () => {
const { admin } = await seed();
const res = await server.post('/api/collections.exportAll', {
const res = await server.post('/api/collections.export_all', {
body: { token: admin.getJwtToken() },
});
@ -204,7 +204,7 @@ describe('#collections.exportAll', async () => {
it('should allow downloading directly', async () => {
const { admin } = await seed();
const res = await server.post('/api/collections.exportAll', {
const res = await server.post('/api/collections.export_all', {
body: { token: admin.getJwtToken(), download: true },
});

View File

@ -7,7 +7,6 @@ import documentMover from '../commands/documentMover';
import {
presentDocument,
presentCollection,
presentRevision,
presentPolicies,
} from '../presenters';
import {
@ -31,8 +30,10 @@ const router = new Router();
router.post('documents.list', auth(), pagination(), async ctx => {
const { sort = 'updatedAt', backlinkDocumentId, parentDocumentId } = ctx.body;
const collectionId = ctx.body.collection;
const createdById = ctx.body.user;
// collection and user are here for backwards compatablity
const collectionId = ctx.body.collectionId || ctx.body.collection;
const createdById = ctx.body.userId || ctx.body.user;
let direction = ctx.body.direction;
if (direction !== 'ASC') direction = 'DESC';
@ -411,54 +412,6 @@ router.post('documents.info', auth({ required: false }), async ctx => {
};
});
router.post('documents.revision', auth(), async ctx => {
let { id, revisionId } = ctx.body;
ctx.assertPresent(id, 'id is required');
ctx.assertPresent(revisionId, 'revisionId is required');
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
const revision = await Revision.findOne({
where: {
id: revisionId,
documentId: document.id,
},
});
ctx.body = {
pagination: ctx.state.pagination,
data: await presentRevision(revision),
};
});
router.post('documents.revisions', auth(), pagination(), async ctx => {
let { id, sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, 'read', document);
const revisions = await Revision.findAll({
where: { documentId: document.id },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
revisions.map(revision => presentRevision(revision))
);
ctx.body = {
pagination: ctx.state.pagination,
data,
};
});
router.post('documents.restore', auth(), async ctx => {
const { id, revisionId } = ctx.body;
ctx.assertPresent(id, 'id is required');
@ -667,6 +620,10 @@ router.post('documents.star', auth(), async ctx => {
data: { title: document.title },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post('documents.unstar', auth(), async ctx => {
@ -690,6 +647,10 @@ router.post('documents.unstar', auth(), async ctx => {
data: { title: document.title },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
router.post('documents.create', auth(), async ctx => {
@ -925,8 +886,8 @@ router.post('documents.move', auth(), async ctx => {
collections: await Promise.all(
collections.map(collection => presentCollection(collection))
),
policies: presentPolicies(user, documents),
},
policies: presentPolicies(user, documents),
};
});

View File

@ -436,48 +436,6 @@ describe('#documents.drafts', async () => {
});
});
describe('#documents.revision', async () => {
it("should return a document's revisions", async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.revisions', {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).not.toEqual(document.id);
expect(body.data[0].title).toEqual(document.title);
});
it('should not return revisions for document in collection not a member of', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
const res = await server.post('/api/documents.revisions', {
body: { token: user.getJwtToken(), id: document.id },
});
expect(res.status).toEqual(403);
});
it('should require authorization', async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post('/api/documents.revisions', {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
expect(res.status).toEqual(403);
});
});
describe('#documents.search', async () => {
it('should return results', async () => {
const { user } = await seed();

View File

@ -18,13 +18,16 @@ const { authorize } = policy;
const router = new Router();
router.post('groups.list', auth(), pagination(), async ctx => {
const { sort = 'updatedAt' } = ctx.body;
let direction = ctx.body.direction;
if (direction !== 'ASC') direction = 'DESC';
const user = ctx.state.user;
let groups = await Group.findAll({
where: {
teamId: user.teamId,
},
order: [['updatedAt', 'DESC']],
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});

View File

@ -8,6 +8,7 @@ import events from './events';
import users from './users';
import collections from './collections';
import documents from './documents';
import revisions from './revisions';
import views from './views';
import hooks from './hooks';
import apiKeys from './apiKeys';
@ -45,6 +46,7 @@ router.use('/', events.routes());
router.use('/', users.routes());
router.use('/', collections.routes());
router.use('/', documents.routes());
router.use('/', revisions.routes());
router.use('/', views.routes());
router.use('/', hooks.routes());
router.use('/', apiKeys.routes());

60
server/api/revisions.js Normal file
View File

@ -0,0 +1,60 @@
// @flow
import Router from 'koa-router';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentRevision } from '../presenters';
import { Document, Revision } from '../models';
import { NotFoundError } from '../errors';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
router.post('revisions.info', auth(), async ctx => {
let { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const revision = await Revision.findByPk(id);
if (!revision) {
throw new NotFoundError();
}
const document = await Document.findByPk(revision.documentId, {
userId: user.id,
});
authorize(user, 'read', document);
ctx.body = {
pagination: ctx.state.pagination,
data: await presentRevision(revision),
};
});
router.post('revisions.list', auth(), pagination(), async ctx => {
let { documentId, sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
ctx.assertPresent(documentId, 'documentId is required');
const user = ctx.state.user;
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, 'read', document);
const revisions = await Revision.findAll({
where: { documentId: document.id },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
revisions.map(revision => presentRevision(revision))
);
ctx.body = {
pagination: ctx.state.pagination,
data,
};
});
export default router;

View File

@ -0,0 +1,92 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildDocument, buildUser } from '../test/factories';
import Revision from '../models/Revision';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe('#revisions.info', async () => {
it('should return a document revision', async () => {
const { user, document } = await seed();
const revision = await Revision.findOne({
where: {
documentId: document.id,
},
});
const res = await server.post('/api/revisions.info', {
body: {
token: user.getJwtToken(),
id: revision.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).not.toEqual(document.id);
expect(body.data.title).toEqual(document.title);
});
it('should require authorization', async () => {
const document = await buildDocument();
const revision = await Revision.findOne({
where: {
documentId: document.id,
},
});
const user = await buildUser();
const res = await server.post('/api/revisions.info', {
body: {
token: user.getJwtToken(),
id: revision.id,
},
});
expect(res.status).toEqual(403);
});
});
describe('#revisions.list', async () => {
it("should return a document's revisions", async () => {
const { user, document } = await seed();
const res = await server.post('/api/revisions.list', {
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).not.toEqual(document.id);
expect(body.data[0].title).toEqual(document.title);
});
it('should not return revisions for document in collection not a member of', async () => {
const { user, document, collection } = await seed();
collection.private = true;
await collection.save();
const res = await server.post('/api/revisions.list', {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
it('should require authorization', async () => {
const document = await buildDocument();
const user = await buildUser();
const res = await server.post('/api/revisions.list', {
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(403);
});
});

View File

@ -11,6 +11,19 @@ const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
router.post('shares.info', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const share = await Share.findByPk(id);
authorize(user, 'read', share);
ctx.body = {
data: presentShare(share),
};
});
router.post('shares.list', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';

View File

@ -1,27 +1,20 @@
// @flow
import uuid from 'uuid';
import Router from 'koa-router';
import format from 'date-fns/format';
import { Op } from '../sequelize';
import {
makePolicy,
getSignature,
publicS3Endpoint,
makeCredential,
} from '../utils/s3';
import { Document, Attachment, Event, User, Team } from '../models';
import { Event, User, Team } from '../models';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import userInviter from '../commands/userInviter';
import { presentUser } from '../presenters';
import policy from '../policies';
const AWS_S3_ACL = process.env.AWS_S3_ACL || 'private';
const { authorize } = policy;
const router = new Router();
router.post('users.list', auth(), pagination(), async ctx => {
const { query, includeSuspended = false } = ctx.body;
const { sort = 'createdAt', query, includeSuspended = false } = ctx.body;
let direction = ctx.body.direction;
if (direction !== 'ASC') direction = 'DESC';
const user = ctx.state.user;
let where = {
@ -48,7 +41,7 @@ router.post('users.list', auth(), pagination(), async ctx => {
const users = await User.findAll({
where,
order: [['createdAt', 'DESC']],
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
@ -81,79 +74,6 @@ router.post('users.update', auth(), async ctx => {
};
});
router.post('users.s3Upload', auth(), async ctx => {
let { name, filename, documentId, contentType, kind, size } = ctx.body;
// backwards compatability
name = name || filename;
contentType = contentType || kind;
ctx.assertPresent(name, 'name is required');
ctx.assertPresent(contentType, 'contentType is required');
ctx.assertPresent(size, 'size is required');
const { user } = ctx.state;
const s3Key = uuid.v4();
const key = `uploads/${user.id}/${s3Key}/${name}`;
const acl =
ctx.body.public === undefined
? AWS_S3_ACL
: ctx.body.public ? 'public-read' : 'private';
const credential = makeCredential();
const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z');
const policy = makePolicy(credential, longDate, acl);
const endpoint = publicS3Endpoint();
const url = `${endpoint}/${key}`;
if (documentId) {
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, 'update', document);
}
const attachment = await Attachment.create({
key,
acl,
size,
url,
contentType,
documentId,
teamId: user.teamId,
userId: user.id,
});
await Event.create({
name: 'user.s3Upload',
data: { name },
teamId: user.teamId,
userId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
data: {
maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE,
uploadUrl: endpoint,
form: {
'Cache-Control': 'max-age=31557600',
'Content-Type': contentType,
acl,
key,
policy,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-credential': credential,
'x-amz-date': longDate,
'x-amz-signature': getSignature(policy),
},
asset: {
contentType,
name,
url: attachment.redirectUrl,
size,
},
},
};
});
// Admin specific
router.post('users.promote', auth(), async ctx => {

View File

@ -49,7 +49,7 @@ export default function Api() {
</Arguments>
</Method>
<Method method="users.s3Upload" label="Get S3 upload credentials">
<Method method="attachments.create" label="Get S3 upload credentials">
<Description>
You can upload small files and images as part of your documents.
All files are stored using Amazon S3. Instead of uploading files
@ -84,7 +84,7 @@ export default function Api() {
Promote a user to be a team admin. This endpoint is only available
for admin users.
</Description>
<Arguments pagination>
<Arguments>
<Argument id="id" description="User ID to be promoted" required />
</Arguments>
</Method>
@ -95,7 +95,7 @@ export default function Api() {
is always required. This endpoint is only available for admin
users.
</Description>
<Arguments pagination>
<Arguments>
<Argument id="id" description="User ID to be demoted" required />
</Arguments>
</Method>
@ -105,7 +105,7 @@ export default function Api() {
Admin can suspend users to reduce the number of accounts on their
billing plan or prevent them from accessing documention.
</Description>
<Arguments pagination>
<Arguments>
<Argument
id="id"
description="User ID to be suspended"
@ -122,7 +122,7 @@ export default function Api() {
Admin can re-active a suspended user. This will update the billing
plan and re-enable their access to the documention.
</Description>
<Arguments pagination>
<Arguments>
<Argument
id="id"
description="User ID to be activated"
@ -173,7 +173,10 @@ export default function Api() {
</Arguments>
</Method>
<Method method="collections.exportAll" label="Export all collections">
<Method
method="collections.export_all"
label="Export all collections"
>
<Description>
Returns a zip file of all the collections or creates an async job
to send a zip file via email to the authenticated user. If
@ -527,20 +530,6 @@ export default function Api() {
</Arguments>
</Method>
<Method method="documents.info" label="Get a document">
<Description>
Get a document with its ID or URL identifier from users
collections.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method
method="documents.restore"
label="Restore a previous revision"
@ -651,32 +640,21 @@ export default function Api() {
</Arguments>
</Method>
<Method
method="documents.revision"
label="Get revision for a document"
>
<Method method="revisions.info" label="Get revision for a document">
<Description>Return a specific revision of a document.</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument id="revisionId" description="Revision ID" required />
<Argument id="id" description="Revision ID" required />
</Arguments>
</Method>
<Method
method="documents.revisions"
label="Get revisions for a document"
>
<Method method="revisions.list" label="Get revisions for a document">
<Description>
Return revisions for a document. Upon each edit, a new revision is
stored.
</Description>
<Arguments pagination>
<Argument
id="id"
id="documentId"
description="Document ID or URI identifier"
required
/>
@ -688,7 +666,7 @@ export default function Api() {
This method allows you to create a new group to organize people in
the team.
</Description>
<Arguments pagination>
<Arguments>
<Argument
id="name"
description="The name of the group"
@ -702,7 +680,7 @@ export default function Api() {
This method allows you to update an existing group. At this time
the only field that can be edited is the name.
</Description>
<Arguments pagination>
<Arguments>
<Argument id="id" description="Group ID" required />
<Argument
id="name"

View File

@ -6,6 +6,7 @@ export default function present(group: Group) {
id: group.id,
name: group.name,
memberCount: group.groupMemberships.length,
createdAt: group.createdAt,
updatedAt: group.updatedAt,
};
}