Browse Source

Update handler logic

Remove duplicate checks and add OPTIONS method handler for allowed
methods.
master
TheoryOfNekomata 7 months ago
parent
commit
de6834773b
6 changed files with 1203 additions and 905 deletions
  1. +12
    -1
      src/backend/common.ts
  2. +57
    -0
      src/backend/http/handlers/default.ts
  3. +9
    -127
      src/backend/http/handlers/resource.ts
  4. +22
    -12
      src/backend/http/server.ts
  5. +0
    -765
      test/e2e/default.test.ts
  6. +1103
    -0
      test/e2e/http.test.ts

+ 12
- 1
src/backend/common.ts View File

@@ -1,5 +1,7 @@
import {ApplicationState, ContentNegotiation} from '../common';
import {ApplicationState, ContentNegotiation, Resource} from '../common';
import {DataSource} from './data-source';
import {BaseSchema} from 'valibot';
import {Middleware} from './http/server';

export interface BackendState {
app: ApplicationState;
@@ -16,3 +18,12 @@ export interface RequestContext {}
export type RequestDecorator = (req: RequestContext) => RequestContext | Promise<RequestContext>;

export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...args: Params) => RequestDecorator;

export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';

export interface AllowedMiddlewareSpecification<Schema extends BaseSchema = BaseSchema> {
method: Method;
middleware: Middleware;
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => BaseSchema;
allowed: (resource: Resource<Schema>) => boolean;
}

+ 57
- 0
src/backend/http/handlers/default.ts View File

@@ -0,0 +1,57 @@
import {HttpMiddlewareError, Middleware, PlainResponse} from '../server';
import {LinkMap} from '../utils';
import {constants} from 'http2';
import {AllowedMiddlewareSpecification} from '../../common';

export const handleGetRoot: Middleware = (req) => {
const { backend, basePath } = req;

const data = {
name: backend.app.name
};

const registeredResources = Array.from(backend.app.resources);
const availableResources = registeredResources.filter((r) => (
r.state.canFetchCollection
|| r.state.canCreate
));

const headers: Record<string, string> = {};
if (availableResources.length > 0) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
headers['Link'] = new LinkMap(
availableResources.map((r) => ({
url: `${basePath}/${r.state.routeName}`,
params: {
rel: 'related',
name: r.state.routeName,
},
}))
)
.toString();
}

return new PlainResponse({
headers,
statusMessage: 'ok',
statusCode: constants.HTTP_STATUS_OK,
body: data
});
};

export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => () => {
if (middlewares.length > 0) {
return new PlainResponse({
headers: {
'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '),
},
statusMessage: 'ok',
statusCode: constants.HTTP_STATUS_NO_CONTENT,
});
}

// TODO add option for custom error handler
throw new HttpMiddlewareError('methodNotAllowed', {
statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED,
});
};

src/backend/http/handlers.ts → src/backend/http/handlers/resource.ts View File

@@ -1,59 +1,10 @@
import { constants } from 'http2';
import * as v from 'valibot';
import {HttpMiddlewareError, PlainResponse, Middleware} from './server';
import {LinkMap} from './utils';

export const handleGetRoot: Middleware = (req) => {
const { backend, basePath } = req;

const data = {
name: backend.app.name
};

const registeredResources = Array.from(backend.app.resources);
const availableResources = registeredResources.filter((r) => (
r.state.canFetchCollection
|| r.state.canCreate
));

const headers: Record<string, string> = {};
if (availableResources.length > 0) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
headers['Link'] = new LinkMap(
availableResources.map((r) => ({
url: `${basePath}/${r.state.routeName}`,
params: {
rel: 'related',
name: r.state.routeName,
},
}))
)
.toString();
}

return new PlainResponse({
headers,
statusMessage: 'ok',
statusCode: constants.HTTP_STATUS_OK,
body: data
});
};
import {HttpMiddlewareError, PlainResponse, Middleware} from '../server';

export const handleGetCollection: Middleware = async (req) => {
const { query, resource, backend } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

let data: v.Output<typeof resource.schema>[];
let totalItemCount: number | undefined;
try {
@@ -88,32 +39,11 @@ export const handleGetCollection: Middleware = async (req) => {
export const handleGetItem: Middleware = async (req) => {
const { resource, resourceId } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

if (typeof resourceId === 'undefined') {
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
}
);
}

if ((resourceId.trim().length ?? 0) < 1) {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
}
);
}
@@ -150,23 +80,11 @@ export const handleGetItem: Middleware = async (req) => {
export const handleDeleteItem: Middleware = async (req) => {
const { resource, resourceId, backend } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

if (typeof resourceId === 'undefined') {
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
}
);
}
@@ -207,23 +125,11 @@ export const handleDeleteItem: Middleware = async (req) => {
export const handlePatchItem: Middleware = async (req) => {
const { resource, resourceId, body } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

if (typeof resourceId === 'undefined') {
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) {
throw new HttpMiddlewareError(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
}
);
}
@@ -264,21 +170,9 @@ export const handlePatchItem: Middleware = async (req) => {
export const handleCreateItem: Middleware = async (req) => {
const { resource, body, backend, basePath } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}
@@ -338,21 +232,9 @@ export const handleCreateItem: Middleware = async (req) => {
export const handleEmplaceItem: Middleware = async (req) => {
const { resource, resourceId, basePath, body, backend } = req;

if (typeof resource === 'undefined') {
throw new HttpMiddlewareError('resourceNotFound', {
statusCode: constants.HTTP_STATUS_NOT_FOUND,
});
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
});
}

+ 22
- 12
src/backend/http/server.ts View File

@@ -1,18 +1,20 @@
import http from 'http';
import {BackendState, RequestContext} from '../common';
import {AllowedMiddlewareSpecification, BackendState, RequestContext} from '../common';
import {Language, Resource, LanguageStatusMessageMap} from '../../common';
import https from 'https';
import {constants} from 'http2';
import * as v from 'valibot';
import {
handleGetRoot, handleOptions,
} from './handlers/default';
import {
handleCreateItem,
handleDeleteItem,
handleEmplaceItem,
handleGetCollection,
handleGetItem,
handleGetRoot,
handlePatchItem,
} from './handlers';
} from './handlers/resource';
import {getBody} from './utils';
import {decorateRequestWithBackend} from './decorators/backend';
import {decorateRequestWithMethod} from './decorators/method';
@@ -87,19 +89,18 @@ export interface CreateServerParams {
streamResponses?: boolean;
}

type ResourceRequestContext = Omit<RequestContext, 'resource'> & Required<Pick<RequestContext, 'resource'>>;
type RequiredResource = Required<Pick<RequestContext, 'resource'>>['resource'];

export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
interface ResourceWithDataSource extends Omit<RequiredResource, 'dataSource'> {
dataSource: Required<Pick<RequiredResource, 'dataSource'>>['dataSource'];
}

type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface ResourceRequestContext extends Omit<RequestContext, 'resource'> {
resource: ResourceWithDataSource;
}

interface AllowedMiddlewareSpecification<Schema extends v.BaseSchema = v.BaseSchema> {
method: Method;
middleware: Middleware;
constructBodySchema?: (resource: Resource<Schema>, resourceId?: string) => v.BaseSchema;
allowed: (resource: Resource<Schema>) => boolean;
export interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
}

const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<T>) => {
@@ -304,6 +305,10 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
});
}

if (req.method === 'OPTIONS') {
return handleOptions(middlewares)(req);
}

if (typeof resource.dataSource === 'undefined') {
throw new HttpMiddlewareError('unableToInitializeResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
@@ -374,6 +379,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
try {
serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined;
} catch (cause) {
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace(
/\$RESOURCE/g,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
@@ -382,6 +390,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
try {
encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined;
} catch (cause) {
res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g,
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
}


+ 0
- 765
test/e2e/default.test.ts View File

@@ -1,765 +0,0 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
tmpdir
} from 'os';
import {
mkdtemp,
rm,
writeFile,
} from 'fs/promises';
import {
join
} from 'path';
import {request, Server} from 'http';
import {constants} from 'http2';
import {DataSource} from '../../src/backend/data-source';
import { dataSources } from '../../src/backend';
import { application, resource, validation as v, Resource } from '../../src';

const PORT = 3000;
const HOST = '127.0.0.1';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];

const highestId = data.reduce<number>(
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
-Infinity
);

if (Number.isFinite(highestId)) {
return (highestId + 1);
}

return 1;
};

describe('yasumi', () => {
let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let server: Server;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);

const backend = app
.createBackend({
dataSource: new dataSources.jsonlFile.DataSource(baseDir),
})
.throwsErrorOnDeletingNotFound();

server = backend.createHttpServer({
basePath: '/api'
});

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
// TODO test status messsages
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual([]);
resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});

it('returns data on HEAD method', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos',
method: 'HEAD',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});
});

describe('serving items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canFetchItem();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
// TODO all responses should have serialized ids
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(data);
resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});

it('returns data on HEAD method', () => {
return new Promise<void>((resolve, reject) => {
// TODO all responses should have serialized ids
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'HEAD',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
Piano.canFetchItem(false);
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});

it('throws on item not found on HEAD method', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'HEAD',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
Piano.canFetchItem(false);
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});
});

describe('creating items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos',
method: 'POST',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
id: 2
});

resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});
});

describe('patching items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `/api/pianos/${data.id}`,
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...data,
...newData,
});
resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});

it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});
});

describe('emplacing items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});

it('returns data for replacement', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `/api/pianos/${newData.id}`,
method: 'PUT',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(newData);
resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});

it('returns data for creation', () => {
return new Promise<void>((resolve, reject) => {
const id = 2;

const req = request(
{
host: HOST,
port: PORT,
path: `/api/pianos/${id}`,
method: 'PUT',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
id,
});
resolve();
});
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify({
...newData,
id,
}));
req.end();
});
});
});

describe('deleting items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canDelete();
});

afterEach(() => {
Piano.canDelete(false);
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `/api/pianos/${data.id}`,
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});
});
});

+ 1103
- 0
test/e2e/http.test.ts
File diff suppressed because it is too large
View File


Loading…
Cancel
Save