@@ -16,6 +16,7 @@ | |||
- [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings) | |||
- [ ] Querying items in collections | |||
- [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`) | |||
- [ ] Declare relationship (e.g. `/users/3/posts`) | |||
- [ ] Tests | |||
- [X] Happy path | |||
- [ ] Error handling | |||
@@ -12,9 +12,9 @@ export interface Backend<T extends DataSource = DataSource> { | |||
dataSource?: (resource: Resource) => T; | |||
} | |||
export interface CreateBackendParams { | |||
export interface CreateBackendParams<T extends DataSource = DataSource> { | |||
app: ApplicationState; | |||
dataSource: DataSource; | |||
dataSource: T; | |||
} | |||
export const createBackend = (params: CreateBackendParams) => { | |||
@@ -279,7 +279,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
if (typeof resource.dataSource === 'undefined') { | |||
throw new ErrorPlainResponse('unableToInitializeResourceDataSource', { | |||
throw new ErrorPlainResponse('unableToBindResourceDataSource', { | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
res: theRes, | |||
}); | |||
@@ -362,16 +362,21 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
resourceReq.resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
res.end(); | |||
return; | |||
} | |||
headers['Content-Type'] = [ | |||
resourceReq.backend.cn.mediaType.name, | |||
`charset=${resourceReq.backend.cn.charset.name}`, | |||
].join('; '); | |||
const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; | |||
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; | |||
res.writeHead(finalErr.statusCode, headers); | |||
typeof serialized !== 'undefined' ? `charset=${resourceReq.backend.cn.charset.name}` : '', | |||
] | |||
.filter((s) => s.length > 0) | |||
.join('; '); | |||
res.statusMessage = resourceReq.backend.cn.language.statusMessages[ | |||
finalErr.statusMessage ?? 'internalServerError' | |||
]?.replace(/\$RESOURCE/g, | |||
resourceReq.resource!.state.itemName); | |||
res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); | |||
if (typeof encoded !== 'undefined') { | |||
res.end(encoded); | |||
return; | |||
@@ -2,6 +2,7 @@ import { constants } from 'http2'; | |||
import * as v from 'valibot'; | |||
import {Middleware} from '../../../common'; | |||
import {ErrorPlainResponse, PlainResponse} from '../response'; | |||
import assert from 'assert'; | |||
export const handleGetCollection: Middleware = async (req, res) => { | |||
const { query, resource, backend } = req; | |||
@@ -39,18 +40,22 @@ export const handleGetCollection: Middleware = async (req, res) => { | |||
}); | |||
}; | |||
const isResourceIdDefined = (resourceId?: string): resourceId is string => !( | |||
typeof resourceId === 'undefined' || resourceId.trim().length < 1 | |||
); | |||
export const handleGetItem: Middleware = async (req, res) => { | |||
const { resource, resourceId } = req; | |||
if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { | |||
throw new ErrorPlainResponse( | |||
assert( | |||
isResourceIdDefined(resourceId), | |||
new ErrorPlainResponse( | |||
'resourceIdNotGiven', | |||
{ | |||
statusCode: constants.HTTP_STATUS_BAD_REQUEST, | |||
res, | |||
} | |||
); | |||
} | |||
) | |||
); | |||
let data: v.Output<typeof resource.schema> | null = null; | |||
try { | |||
@@ -184,17 +189,21 @@ export const handlePatchItem: Middleware = async (req, res) => { | |||
}); | |||
}; | |||
const isIdAttributeDefined = (idAttr?: unknown): idAttr is string => ( | |||
typeof idAttr !== 'undefined' | |||
); | |||
export const handleCreateItem: Middleware = async (req, res) => { | |||
const { resource, body, backend, basePath } = req; | |||
const idAttrRaw = resource.state.shared.get('idAttr'); | |||
if (typeof idAttrRaw === 'undefined') { | |||
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { | |||
const idAttr = resource.state.shared.get('idAttr'); | |||
assert( | |||
isIdAttributeDefined(idAttr), | |||
new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
res, | |||
}); | |||
} | |||
const idAttr = idAttrRaw as string; | |||
}) | |||
); | |||
let newId; | |||
let params: v.Output<typeof resource.schema>; | |||
@@ -203,7 +212,7 @@ export const handleCreateItem: Middleware = async (req, res) => { | |||
params = { ...body as Record<string, unknown> }; | |||
params[idAttr] = newId; | |||
} catch (cause) { | |||
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { | |||
throw new ErrorPlainResponse('unableToAssignIdFromResourceDataSource', { | |||
cause, | |||
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, | |||
res, | |||
@@ -220,10 +229,11 @@ export const handleCreateItem: Middleware = async (req, res) => { | |||
let totalItemCount: number | undefined; | |||
try { | |||
newObject = await resource.dataSource.create(params); | |||
if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { | |||
totalItemCount = await resource.dataSource.getTotalCount(); | |||
totalItemCount += 1; | |||
} | |||
newObject = await resource.dataSource.create(params); | |||
} catch (cause) { | |||
throw new ErrorPlainResponse('unableToCreateResource', { | |||
cause, | |||
@@ -49,7 +49,7 @@ export interface Application< | |||
export const application = (appParams: ApplicationParams): Application => { | |||
const appState: ApplicationState = { | |||
name: appParams.name, | |||
resources: new Set<Resource<any>>(), | |||
resources: new Set<Resource>(), | |||
languages: new Map<Language['name'], Language>([ | |||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | |||
]), | |||
@@ -17,7 +17,9 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||
'resourceNotFound', | |||
'deleteNonExistingResource', | |||
'unableToCreateResource', | |||
'unableToBindResourceDataSource', | |||
'unableToGenerateIdFromResourceDataSource', | |||
'unableToAssignIdFromResourceDataSource', | |||
'unableToEmplaceResource', | |||
'unableToSerializeResponse', | |||
'unableToEncodeResponse', | |||
@@ -35,6 +37,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ | |||
'resourceReplaced', | |||
'notImplemented', | |||
'provideOptions', | |||
'internalServerError', | |||
] as const; | |||
export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number]; | |||
@@ -62,6 +65,7 @@ export const FALLBACK_LANGUAGE = { | |||
statusMessages: { | |||
unableToSerializeResponse: 'Unable To Serialize Response', | |||
unableToEncodeResponse: 'Unable To Encode Response', | |||
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', | |||
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', | |||
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', | |||
unableToFetchResource: 'Unable To Fetch $RESOURCE', | |||
@@ -90,10 +94,12 @@ export const FALLBACK_LANGUAGE = { | |||
resourceCreated: '$RESOURCE Created', | |||
resourceReplaced: '$RESOURCE Replaced', | |||
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', | |||
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', | |||
unableToEmplaceResource: 'Unable To Emplace $RESOURCE', | |||
resourceIdNotGiven: '$RESOURCE ID Not Given', | |||
unableToCreateResource: 'Unable To Create $RESOURCE', | |||
notImplemented: 'Not Implemented' | |||
notImplemented: 'Not Implemented', | |||
internalServerError: 'Internal Server Error', | |||
}, | |||
bodies: { | |||
languageNotAcceptable: [], | |||
@@ -1,10 +1,12 @@ | |||
import * as v from 'valibot'; | |||
import {BaseSchema} from 'valibot'; | |||
export interface ResourceState< | |||
ItemName extends string = string, | |||
RouteName extends string = string | |||
> { | |||
shared: Map<string, unknown>; | |||
relationships: Set<Resource>; | |||
itemName: ItemName; | |||
routeName: RouteName; | |||
canCreate: boolean; | |||
@@ -30,6 +32,7 @@ export interface Resource< | |||
canPatch(b?: boolean): this; | |||
canEmplace(b?: boolean): this; | |||
canDelete(b?: boolean): this; | |||
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this; | |||
} | |||
export const resource = < | |||
@@ -39,6 +42,7 @@ export const resource = < | |||
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => { | |||
const resourceState = { | |||
shared: new Map(), | |||
relationships: new Set<Resource>(), | |||
canCreate: false, | |||
canFetchCollection: false, | |||
canFetchItem: false, | |||
@@ -105,6 +109,10 @@ export const resource = < | |||
get schema() { | |||
return schema; | |||
}, | |||
relatedTo<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) { | |||
resourceState.relationships.add(resource); | |||
return this; | |||
}, | |||
} as Resource<Schema, CurrentName, CurrentRouteName>; | |||
}; | |||
@@ -0,0 +1,478 @@ | |||
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 {constants} from 'http2'; | |||
import {Backend, dataSources} from '../../../src/backend'; | |||
import { application, resource, validation as v, Resource } from '../../../src/common'; | |||
import { autoIncrement } from '../../fixtures'; | |||
import {createTestClient, TestClient} from '../../utils'; | |||
const PORT = 3000; | |||
const HOST = '127.0.0.1'; | |||
const BASE_PATH = '/api'; | |||
const ACCEPT = 'application/json'; | |||
const ACCEPT_LANGUAGE = 'en'; | |||
const ACCEPT_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE = ACCEPT; | |||
describe('happy path', () => { | |||
let client: TestClient; | |||
beforeEach(() => { | |||
client = createTestClient({ | |||
host: HOST, | |||
port: PORT, | |||
}) | |||
.acceptMediaType(ACCEPT) | |||
.acceptLanguage(ACCEPT_LANGUAGE) | |||
.acceptCharset(ACCEPT_CHARSET) | |||
.contentType(CONTENT_TYPE) | |||
.contentCharset(CONTENT_TYPE_CHARSET); | |||
}); | |||
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 backend: Backend; | |||
let server: ReturnType<Backend['createHttpServer']>; | |||
beforeEach(() => { | |||
const app = application({ | |||
name: 'piano-service', | |||
}) | |||
.resource(Piano); | |||
backend = app.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||
}); | |||
server = backend.createHttpServer({ | |||
basePath: BASE_PATH | |||
}); | |||
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', async () => { | |||
const [res, resData] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
// TODO test status messages | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual([]); | |||
}); | |||
it('returns data on HEAD method', async () => { | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('GET'); | |||
expect(allowedMethods).toContain('HEAD'); | |||
}); | |||
}); | |||
describe('serving items', () => { | |||
const existingResource = { | |||
id: 1, | |||
brand: 'Yamaha' | |||
}; | |||
beforeEach(async () => { | |||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canFetchItem(); | |||
return new Promise((resolve) => { | |||
setTimeout(() => { | |||
resolve(); | |||
}); | |||
}); | |||
}); | |||
afterEach(() => { | |||
Piano.canFetchItem(false); | |||
}); | |||
it('returns data', async () => { | |||
const [res, resData] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual(existingResource); | |||
}); | |||
it('returns data on HEAD method', async () => { | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('GET'); | |||
expect(allowedMethods).toContain('HEAD'); | |||
}); | |||
}); | |||
describe('creating items', () => { | |||
const existingResource = { | |||
id: 1, | |||
brand: 'Yamaha' | |||
}; | |||
const newResourceData = { | |||
brand: 'K. Kawai' | |||
}; | |||
beforeEach(async () => { | |||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canCreate(); | |||
}); | |||
afterEach(() => { | |||
Piano.canCreate(false); | |||
}); | |||
it('returns data', async () => { | |||
const [res, resData] = await client({ | |||
path: `${BASE_PATH}/pianos`, | |||
method: 'POST', | |||
body: newResourceData, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual({ | |||
...newResourceData, | |||
id: 2 | |||
}); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('POST'); | |||
}); | |||
}); | |||
describe('patching items', () => { | |||
const existingResource = { | |||
id: 1, | |||
brand: 'Yamaha' | |||
}; | |||
const patchData = { | |||
brand: 'K. Kawai' | |||
}; | |||
beforeEach(async () => { | |||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canPatch(); | |||
}); | |||
afterEach(() => { | |||
Piano.canPatch(false); | |||
}); | |||
it('returns data', async () => { | |||
const [res, resData] = await client({ | |||
method: 'PATCH', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
body: patchData, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual({ | |||
...existingResource, | |||
...patchData, | |||
}); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('PATCH'); | |||
}); | |||
}); | |||
describe('emplacing items', () => { | |||
const existingResource = { | |||
id: 1, | |||
brand: 'Yamaha' | |||
}; | |||
const emplaceResourceData = { | |||
id: 1, | |||
brand: 'K. Kawai' | |||
}; | |||
beforeEach(async () => { | |||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canEmplace(); | |||
}); | |||
afterEach(() => { | |||
Piano.canEmplace(false); | |||
}); | |||
it('returns data for replacement', async () => { | |||
const [res, resData] = await client({ | |||
method: 'PUT', | |||
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, | |||
body: emplaceResourceData, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual(emplaceResourceData); | |||
}); | |||
it('returns data for creation', async () => { | |||
const newId = 2; | |||
const [res, resData] = await client({ | |||
method: 'PUT', | |||
path: `${BASE_PATH}/pianos/${newId}`, | |||
body: { | |||
...emplaceResourceData, | |||
id: newId, | |||
}, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); | |||
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); | |||
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`); | |||
if (typeof resData === 'undefined') { | |||
expect.fail('Response body must be defined.'); | |||
return; | |||
} | |||
expect(resData).toEqual({ | |||
...emplaceResourceData, | |||
id: newId, | |||
}); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('PUT'); | |||
}); | |||
}); | |||
describe('deleting items', () => { | |||
const existingResource = { | |||
id: 1, | |||
brand: 'Yamaha' | |||
}; | |||
beforeEach(async () => { | |||
const resourcePath = join(baseDir, 'pianos.jsonl'); | |||
await writeFile(resourcePath, JSON.stringify(existingResource)); | |||
}); | |||
beforeEach(() => { | |||
Piano.canDelete(); | |||
}); | |||
afterEach(() => { | |||
Piano.canDelete(false); | |||
}); | |||
it('responds', async () => { | |||
const [res, resData] = await client({ | |||
method: 'DELETE', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
expect(res.headers).not.toHaveProperty('content-type'); | |||
expect(resData).toBeUndefined(); | |||
}); | |||
it('returns options', async () => { | |||
const [res] = await client({ | |||
method: 'OPTIONS', | |||
path: `${BASE_PATH}/pianos/${existingResource.id}`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); | |||
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; | |||
expect(allowedMethods).toContain('DELETE'); | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,764 @@ | |||
import { | |||
beforeAll, | |||
afterAll, | |||
afterEach, | |||
beforeEach, | |||
describe, | |||
expect, | |||
it, vi, | |||
} from 'vitest'; | |||
import { | |||
tmpdir | |||
} from 'os'; | |||
import { | |||
mkdtemp, | |||
rm, | |||
writeFile, | |||
} from 'fs/promises'; | |||
import { | |||
join | |||
} from 'path'; | |||
import {request} from 'http'; | |||
import {constants} from 'http2'; | |||
import {Backend, dataSources} from '../../../src/backend'; | |||
import { application, resource, validation as v, Resource } from '../../../src/common'; | |||
import { autoIncrement } from '../../fixtures'; | |||
import {createTestClient, TestClient} from '../../utils'; | |||
import {DataSource} from '../../../src/backend/data-source'; | |||
const PORT = 3001; | |||
const HOST = '127.0.0.1'; | |||
const BASE_PATH = '/api'; | |||
const ACCEPT = 'application/json'; | |||
const ACCEPT_LANGUAGE = 'en'; | |||
const ACCEPT_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE_CHARSET = 'utf-8'; | |||
const CONTENT_TYPE = ACCEPT; | |||
class DummyDataSource implements DataSource { | |||
private resource?: { dataSource?: unknown }; | |||
create(): Promise<never> { | |||
throw new Error(); | |||
} | |||
delete(): Promise<never> { | |||
throw new Error(); | |||
} | |||
emplace(): Promise<never> { | |||
throw new Error(); | |||
} | |||
getById(): Promise<never> { | |||
throw new Error(); | |||
} | |||
newId(): Promise<never> { | |||
throw new Error(); | |||
} | |||
getMultiple(): Promise<never> { | |||
throw new Error(); | |||
} | |||
getSingle(): Promise<never> { | |||
throw new Error(); | |||
} | |||
getTotalCount(): Promise<never> { | |||
throw new Error(); | |||
} | |||
async initialize(): Promise<void> {} | |||
patch(): Promise<never> { | |||
throw new Error(); | |||
} | |||
prepareResource(rr: unknown) { | |||
this.resource = rr as unknown as { dataSource: DummyDataSource }; | |||
this.resource.dataSource = this; | |||
} | |||
} | |||
describe('error handling', () => { | |||
let client: TestClient; | |||
beforeEach(() => { | |||
client = createTestClient({ | |||
host: HOST, | |||
port: PORT, | |||
}) | |||
.acceptMediaType(ACCEPT) | |||
.acceptLanguage(ACCEPT_LANGUAGE) | |||
.acceptCharset(ACCEPT_CHARSET) | |||
.contentType(CONTENT_TYPE) | |||
.contentCharset(CONTENT_TYPE_CHARSET); | |||
}); | |||
describe('on internal errors', () => { | |||
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 dataSource: DataSource; | |||
let backend: Backend; | |||
let server: ReturnType<Backend['createHttpServer']>; | |||
beforeEach(() => { | |||
const app = application({ | |||
name: 'piano-service', | |||
}) | |||
.resource(Piano); | |||
dataSource = new DummyDataSource(); | |||
backend = app.createBackend({ | |||
dataSource, | |||
}); | |||
server = backend.createHttpServer({ | |||
basePath: BASE_PATH | |||
}); | |||
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.skip('serving collections', () => { | |||
beforeEach(() => { | |||
Piano.canFetchCollection(); | |||
return new Promise((resolve) => { | |||
setTimeout(() => { | |||
resolve(); | |||
}); | |||
}); | |||
}); | |||
afterEach(() => { | |||
Piano.canFetchCollection(false); | |||
}); | |||
it('throws on query', async () => { | |||
const [res] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); | |||
}); | |||
it('throws on HEAD method', async () => { | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); | |||
}); | |||
}); | |||
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('throws on query', async () => { | |||
const [res] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | |||
}); | |||
it('throws on HEAD method', async () => { | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); | |||
}); | |||
it('throws on item not found', async () => { | |||
const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); | |||
getById.mockResolvedValueOnce(null as never); | |||
const [res] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
}); | |||
it('throws on item not found on HEAD method', async () => { | |||
const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); | |||
getById.mockResolvedValueOnce(null as never); | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
}); | |||
}); | |||
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('throws on error assigning ID', async () => { | |||
const [res] = await client({ | |||
method: 'POST', | |||
path: `${BASE_PATH}/pianos`, | |||
body: newData, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source'); | |||
}); | |||
it('throws on error creating resource', async () => { | |||
const getById = vi.spyOn(DummyDataSource.prototype, 'newId'); | |||
getById.mockResolvedValueOnce(data.id as never); | |||
const [res] = await client({ | |||
method: 'POST', | |||
path: `${BASE_PATH}/pianos`, | |||
body: newData, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano'); | |||
}); | |||
}); | |||
describe.skip('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('throws on item to patch not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: `${BASE_PATH}/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.skip('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); | |||
}); | |||
}); | |||
describe.skip('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(); | |||
backend.throwsErrorOnDeletingNotFound(); | |||
}); | |||
afterEach(() => { | |||
Piano.canDelete(false); | |||
backend.throwsErrorOnDeletingNotFound(false); | |||
}); | |||
it('throws on item not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: `${BASE_PATH}/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(); | |||
}); | |||
}); | |||
}); | |||
}); | |||
describe('on data source errors', () => { | |||
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 backend: Backend; | |||
let server: ReturnType<Backend['createHttpServer']>; | |||
beforeEach(() => { | |||
const app = application({ | |||
name: 'piano-service', | |||
}) | |||
.resource(Piano); | |||
backend = app.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource(baseDir), | |||
}); | |||
server = backend.createHttpServer({ | |||
basePath: BASE_PATH | |||
}); | |||
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.skip('serving collections', () => { | |||
beforeEach(() => { | |||
Piano.canFetchCollection(); | |||
return new Promise((resolve) => { | |||
setTimeout(() => { | |||
resolve(); | |||
}); | |||
}); | |||
}); | |||
afterEach(() => { | |||
Piano.canFetchCollection(false); | |||
}); | |||
}); | |||
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('throws on item not found', async () => { | |||
const [res] = await client({ | |||
method: 'GET', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
}); | |||
it('throws on item not found on HEAD method', async () => { | |||
const [res] = await client({ | |||
method: 'HEAD', | |||
path: `${BASE_PATH}/pianos/2`, | |||
}); | |||
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); | |||
}); | |||
}); | |||
describe.skip('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); | |||
}); | |||
}); | |||
describe.skip('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('throws on item to patch not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: `${BASE_PATH}/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.skip('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); | |||
}); | |||
}); | |||
describe.skip('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(); | |||
backend.throwsErrorOnDeletingNotFound(); | |||
}); | |||
afterEach(() => { | |||
Piano.canDelete(false); | |||
backend.throwsErrorOnDeletingNotFound(false); | |||
}); | |||
it('throws on item not found', () => { | |||
return new Promise<void>((resolve, reject) => { | |||
const req = request( | |||
{ | |||
host: HOST, | |||
port: PORT, | |||
path: `${BASE_PATH}/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(); | |||
}); | |||
}); | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,143 @@ | |||
import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; | |||
import {Method} from '../src/backend/common'; | |||
interface ClientParams { | |||
method: Method; | |||
path: string; | |||
headers?: OutgoingHttpHeaders; | |||
body?: unknown; | |||
} | |||
type ResponseBody = Buffer | string | object; | |||
export interface TestClient { | |||
(params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>; | |||
acceptMediaType(mediaType: string): this; | |||
acceptLanguage(language: string): this; | |||
acceptCharset(charset: string): this; | |||
contentType(mediaType: string): this; | |||
contentCharset(charset: string): this; | |||
} | |||
export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'>): TestClient => { | |||
const additionalHeaders: OutgoingHttpHeaders = {}; | |||
const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { | |||
const { | |||
'content-type': contentTypeHeader, | |||
...etcAdditionalHeaders | |||
} = additionalHeaders; | |||
const headers: OutgoingHttpHeaders = { | |||
...(options.headers ?? {}), | |||
...(params.headers ?? {}), | |||
...etcAdditionalHeaders, | |||
}; | |||
if (typeof params.body !== 'undefined') { | |||
headers['content-type'] = contentTypeHeader; | |||
} | |||
const req = request({ | |||
...options, | |||
method: params.method, | |||
path: params.path, | |||
headers, | |||
}); | |||
req.on('response', (res) => { | |||
res.on('error', (err) => { | |||
reject(err); | |||
}); | |||
let resBuffer: Buffer | undefined; | |||
res.on('data', (c) => { | |||
resBuffer = ( | |||
typeof resBuffer === 'undefined' | |||
? Buffer.from(c) | |||
: Buffer.concat([resBuffer, c]) | |||
); | |||
}); | |||
res.on('close', () => { | |||
const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept']; | |||
const contentTypeBase = acceptHeader ?? 'application/octet-stream'; | |||
const [type, subtype] = contentTypeBase.split('/'); | |||
const allSubtypes = subtype.split('+'); | |||
if (typeof resBuffer !== 'undefined') { | |||
if (allSubtypes.includes('json')) { | |||
const acceptCharset = ( | |||
Array.isArray(headers['accept-charset']) | |||
? headers['accept-charset'].join('; ') | |||
: headers['accept-charset'] | |||
) as BufferEncoding | undefined; | |||
resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]); | |||
return; | |||
} | |||
if (type === 'text') { | |||
const acceptCharset = ( | |||
Array.isArray(headers['accept-charset']) | |||
? headers['accept-charset'].join('; ') | |||
: headers['accept-charset'] | |||
) as BufferEncoding | undefined; | |||
resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]); | |||
return; | |||
} | |||
resolve([res, resBuffer]); | |||
return; | |||
} | |||
resolve([res]); | |||
}); | |||
}); | |||
req.on('error', (err) => { | |||
reject(err); | |||
}) | |||
if (typeof params.body !== 'undefined') { | |||
const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString(); | |||
const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream'; | |||
const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim()); | |||
const charsetParam = contentTypeParams.find((s) => s.startsWith('charset=')); | |||
const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined; | |||
const [, subtype] = contentTypeBase.split('/'); | |||
const allSubtypes = subtype.split('+'); | |||
req.write( | |||
allSubtypes.includes('json') | |||
? JSON.stringify(params.body) | |||
: Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined) | |||
); | |||
} | |||
req.end(); | |||
}); | |||
client.acceptMediaType = function acceptMediaType(mediaType: string) { | |||
additionalHeaders['accept'] = mediaType; | |||
return this; | |||
}; | |||
client.acceptLanguage = function acceptLanguage(language: string) { | |||
additionalHeaders['accept-language'] = language; | |||
return this; | |||
}; | |||
client.acceptCharset = function acceptCharset(charset: string) { | |||
additionalHeaders['accept-charset'] = charset; | |||
return this; | |||
}; | |||
client.contentType = function contentType(mediaType: string) { | |||
additionalHeaders['content-type'] = mediaType; | |||
return this; | |||
}; | |||
client.contentCharset = function contentCharset(charset: string) { | |||
additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`; | |||
return this; | |||
}; | |||
return client; | |||
}; |