Skip to content

Commit

Permalink
feat(asset): create asset functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
intellix committed Jun 6, 2019
1 parent 43a8586 commit 4955dd5
Show file tree
Hide file tree
Showing 24 changed files with 885 additions and 19 deletions.
5 changes: 5 additions & 0 deletions packages/prime-core/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
DATABASE_URL=postgresql://localhost:5432/prime
TEST_DATABASE_URL=postgresql://localhost:5432/prime-test
SESSION_SECRET=keyboard-cat-dart

S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
2 changes: 2 additions & 0 deletions packages/prime-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@
"@primecms/field-slice": "^0.3.4-beta.0",
"@primecms/field-string": "^0.3.4-beta.0",
"apollo-server-express": "2.4.0",
"aws-sdk": "*",
"class-validator": "0.9.1",
"cors": "2.8.5",
"dataloader": "1.4.0",
"debug": "4.1.1",
"dotenv": "6.2.0",
"express": "4.16.4",
"file-type": "^11.0.0",
"graphql": "^14.1.1",
"graphql-iso-date": "3.6.1",
"graphql-type-json": "^0.2.1",
Expand Down
66 changes: 66 additions & 0 deletions packages/prime-core/src/entities/Asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { User } from '@accounts/typeorm';
import { GraphQLFloat } from 'graphql';
import { Field, ID, ObjectType } from 'type-graphql';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity()
@ObjectType()
export class Asset {
@Field(type => ID)
@PrimaryGeneratedColumn('uuid')
public id: string;

@CreateDateColumn()
@Field({ nullable: true })
public createdAt: Date;

@UpdateDateColumn()
@Field({ nullable: true })
public updatedAt: Date;

@Column({ type: 'timestamp', nullable: true })
@Field({ nullable: true })
public deletedAt: Date;

@Column({ nullable: false })
@Field({ nullable: false })
public url: string;

@Column({ nullable: false })
@Field({ nullable: false })
public mimeType: string;

@Column({ nullable: false })
@Field({ nullable: false })
public handle: string;

@Column({ nullable: false })
@Field({ nullable: false })
public fileName: string;

@Column({ nullable: false })
@Field({ nullable: false })
public fileSize: number;

@Column('float', { nullable: true })
@Field(type => GraphQLFloat, { nullable: true })
public width: number;

@Column('float', { nullable: true })
@Field(type => GraphQLFloat, { nullable: true })
public height: number;

@Column({ nullable: true })
@Field({ nullable: true })
public userId?: string;

@ManyToOne(type => User, { nullable: true, onDelete: 'SET NULL' })
public user: User;
}
23 changes: 23 additions & 0 deletions packages/prime-core/src/modules/external/interfaces/Upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ReadStream } from 'fs';

/**
* File upload details, resolved from an `Upload` scalar promise.
* See: https://github.com/jaydenseric/graphql-upload
*/
export interface Upload {
/** File name */
filename: string;

/** File MIME type. Provided by the client and can’t be trusted. */
mimetype: string;

/** File stream transfer encoding. */
encoding: string;

/**
* createReadStream Returns a Node.js readable stream of the file contents,
* for processing and storing the file. Multiple calls create independent streams.
* Throws if called after all resolvers have resolved, or after an error has interrupted the request.
*/
createReadStream: () => ReadStream;
}
2 changes: 2 additions & 0 deletions packages/prime-core/src/modules/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Connection } from 'typeorm';
import { pubSub } from '../../utils/pubSub';
import { createAccounts } from '../accounts';
import { AccessTokenResolver } from './resolvers/AccessTokenResolver';
import { AssetResolver } from './resolvers/AssetResolver';
import { DocumentResolver } from './resolvers/DocumentResolver';
import { PrimeResolver } from './resolvers/PrimeResolver';
import { ReleaseResolver } from './resolvers/ReleaseResolver';
Expand All @@ -29,6 +30,7 @@ export const createInternal = async (connection: Connection) => {
const schema = await buildTypeDefsAndResolvers({
resolvers: [
AccessTokenResolver,
AssetResolver,
DocumentResolver,
PrimeResolver,
ReleaseResolver,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EntityRepository } from 'typeorm';
import { Asset } from '../../../entities/Asset';
import { DataLoaderRepository } from './DataLoaderRepository';

@EntityRepository(Asset)
export class AssetRepository extends DataLoaderRepository<Asset> {}
111 changes: 111 additions & 0 deletions packages/prime-core/src/modules/internal/resolvers/AssetResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Context } from 'apollo-server-core';
import { GraphQLResolveInfo } from 'graphql';
import {
Arg,
Args,
Ctx,
FieldResolver,
ID,
Info,
Mutation,
Query,
registerEnumType,
Resolver,
Root,
} from 'type-graphql';
import Container from 'typedi';
import { getRepository } from 'typeorm';
import { EntityConnection } from 'typeorm-cursor-connection';
import { InjectRepository } from 'typeorm-typedi-extensions';
import { Asset } from '../../../entities/Asset';
import { S3Storage } from '../../../utils/S3Storage';
import { AssetRepository } from '../repositories/AssetRepository';
import { AssetInput } from '../types/AssetInput';
import { ConnectionArgs, createConnectionType } from '../types/createConnectionType';
import { User } from '../types/User';
import { Authorized } from '../utils/Authorized';

const AssetConnection = createConnectionType(Asset);

enum AssetOrder {
updatedAt_ASC,
updatedAt_DESC,
id_ASC,
id_DESC,
fileName_ASC,
fileName_DESC,
}

registerEnumType(AssetOrder, {
name: 'AssetConnectionOrder',
});

@Resolver(of => Asset)
export class AssetResolver {
@InjectRepository(AssetRepository)
private readonly assetRepository: AssetRepository;
private readonly storage: S3Storage;

constructor() {
this.storage = Container.get(S3Storage);
}

@Authorized()
@Query(returns => Asset, { nullable: true, description: 'Get Asset by ID' })
public Asset(
@Arg('id', type => ID) id: string,
@Ctx() context: Context,
@Info() info: GraphQLResolveInfo
) {
return this.assetRepository.loadOne(id);
}

@Authorized()
@Query(returns => AssetConnection, { description: 'Get many Assets' })
public async allAssets(
@Args() args: ConnectionArgs,
@Arg('order', type => AssetOrder, { defaultValue: 0 }) orderBy: string
) {
const [sort, order]: any = orderBy.split('_');
const connection = await new EntityConnection(args, {
repository: this.assetRepository,
sortOptions: [{ sort: `Asset.${sort}`, order }],
});
return {
edges: await connection.edges,
totalCount: await this.assetRepository.count(),
};
}

@Authorized()
@Mutation(returns => Asset, { description: 'Create Asset' })
public async createAsset(
@Arg('input') input: AssetInput,
@Ctx() context: Context
): Promise<Asset> {
const upload = await input.upload;
const assetInput = await this.storage.upload(upload.filename, upload.createReadStream());
const asset = this.assetRepository.create(assetInput);
await this.assetRepository.save(asset);
return asset;
}

@Authorized()
@Mutation(returns => Boolean, { description: 'Remove Asset by ID' })
public async removeAsset(
@Arg('id', type => ID) id: string,
@Ctx() context: Context
): Promise<boolean> {
const entity = await this.assetRepository.findOneOrFail(id);
await this.storage.delete(entity.fileName);
return Boolean(await this.assetRepository.remove(entity));
}

@FieldResolver(returns => User, { description: 'Get Asset User' })
public user(@Root() asset: Asset): Promise<User> {
return getRepository(User).findOneOrFail({
cache: 1000,
where: asset.user,
});
}
}
9 changes: 9 additions & 0 deletions packages/prime-core/src/modules/internal/types/AssetInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { GraphQLUpload } from 'graphql-upload';
import { Field, InputType } from 'type-graphql';
import { Upload } from '../../external/interfaces/Upload';

@InputType()
export class AssetInput {
@Field(type => GraphQLUpload, { nullable: false })
public upload: Promise<Upload>;
}
77 changes: 77 additions & 0 deletions packages/prime-core/src/utils/S3Storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import S3 from 'aws-sdk/clients/s3';
import fileType from 'file-type';
import { ReadStream } from 'fs';
import { Service } from 'typedi';
import uuidv4 from 'uuid/v4';
import { Asset } from '../entities/Asset';

@Service()
export class S3Storage {
private bucket: string;
private s3: S3;

constructor() {
if (!process.env.S3_BUCKET) {
throw Error('S3_BUCKET not set');
}
if (!process.env.S3_ACCESS_KEY_ID) {
throw Error('S3_ACCESS_KEY_ID not set');
}
if (!process.env.S3_SECRET_ACCESS_KEY) {
throw Error('S3_SECRET_ACCESS_KEY not set');
}

this.bucket = process.env.S3_BUCKET;
this.s3 = new S3({
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
}

public async upload(fileName: string, readStream1: ReadStream): Promise<Partial<Asset>> {
// TODO: Detect width/height of image types

const readStream2 = await fileType.stream(readStream1);
const { mime } = readStream2.fileType!;

const handle = uuidv4();
const data = await this.s3
.upload({
Bucket: this.bucket,
ACL: 'public-read',
Body: readStream2,
Key: handle,
ContentType: mime,
})
.promise();
const head = await this.head(handle);
return {
fileName,
fileSize: head.ContentLength,
handle,
mimeType: mime,
url: data.Location,
};
}

public async delete(fileName: string): Promise<boolean> {
const data = await this.s3
.deleteObject({
Bucket: this.bucket,
Key: fileName,
})
.promise();
return data.DeleteMarker || false;
}

private async head(fileName: string) {
return this.s3
.headObject({
Bucket: this.bucket,
Key: fileName,
})
.promise();
}
}
7 changes: 6 additions & 1 deletion packages/prime-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@primecms/field-select": "^0.3.4-beta.0",
"@primecms/field-slice": "^0.3.4-beta.0",
"@primecms/field-string": "^0.3.4-beta.0",
"@types/apollo-upload-client": "^8.1.1",
"@types/graphql": "14.2.0",
"@types/jest": "23.3.14",
"@types/lodash": "4.14.133",
Expand All @@ -46,6 +47,7 @@
"apollo-link-context": "1.0.17",
"apollo-link-error": "1.1.10",
"apollo-link-http": "1.5.14",
"apollo-upload-client": "^10.0.1",
"babel-plugin-import": "1.12.0",
"braft-editor": "2.2.9",
"date-fns": "1.30.1",
Expand Down Expand Up @@ -80,5 +82,8 @@
"publishConfig": {
"access": "public"
},
"gitHead": "f00baf08a686c40cd5cc34fa4facdacf605b7e0c"
"gitHead": "f00baf08a686c40cd5cc34fa4facdacf605b7e0c",
"dependencies": {
"apollo-upload-client": "^10.0.1"
}
}
2 changes: 2 additions & 0 deletions packages/prime-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { observer } from 'mobx-react';
import React from 'react';
import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { AssetsList } from './routes/assets/AssetsList';
import { DocumentsDetail } from './routes/documents/DocumentsDetail';
import { DocumentsList } from './routes/documents/DocumentsList';
import { Login } from './routes/login/Login';
Expand Down Expand Up @@ -71,6 +72,7 @@ export class App extends React.Component {
<Route path="/" exact render={() => <Redirect to="/documents" />} />
<Route path="/schemas" component={Schemas} />
<Route path="/documents" exact component={DocumentsList} />
<Route path="/assets" exact component={AssetsList} />
<Route path="/playground" exact component={Playground} />
<Route
path="/documents/doc/:entryId/:options?"
Expand Down
6 changes: 6 additions & 0 deletions packages/prime-ui/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export const Layout = withRouter(({ children, history, location }: any) => {
<span>Schemas</span>
</NavLink>
</Menu.Item>
<Menu.Item key="assets">
<NavLink to="/assets" className="nav-text">
<Icon type="file-image" />
<span>Assets</span>
</NavLink>
</Menu.Item>
<Menu.Item key="playground">
<NavLink to="/playground" className="nav-text">
<Icon type="code" />
Expand Down
Loading

0 comments on commit 4955dd5

Please sign in to comment.