Skip to content

Commit

Permalink
feat: handle application/x-www-form-urlencoded format on POSTS (#371)
Browse files Browse the repository at this point in the history
* chore: add Unsupported Media Type response and refactor the error reponse

* feat: handle application/x-www-form-urlencoded format on POSTS

* fix: typo

* test: content_type metadata field

* refactor: improve type safety on httpResponse function
  • Loading branch information
matteo-cristino authored Oct 16, 2024
1 parent 98a0ed4 commit aaaf48b
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 89 deletions.
9 changes: 4 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Then print the 'result'
.end(JSON.stringify(result));
});
} catch (e) {
internalServerError(res, L, e as Error);
internalServerError(res, e as Error);
}
})
.get('/sayhi', (res) => {
Expand All @@ -164,7 +164,7 @@ const generatePublicDirectory = (app: TemplatedApp) => {
});
let url = req.getUrl();
if (url.split('/').pop()?.startsWith('.')) {
notFound(res, L, new Error('Try to access hidden file'));
notFound(res, new Error('Try to access hidden file'));
return;
}
//remove basepath from the beginning of the url if it is present
Expand All @@ -182,7 +182,6 @@ const generatePublicDirectory = (app: TemplatedApp) => {
} catch (e) {
unprocessableEntity(
res,
L,
new Error(`Malformed metadata file: ${(e as Error).message}`)
);
return;
Expand All @@ -193,7 +192,7 @@ const generatePublicDirectory = (app: TemplatedApp) => {
const data: Record<string, unknown> = getQueryParams(req);
await runPrecondition(path.join(publicDirectory, publicMetadata.precondition), data);
} catch (e) {
forbidden(res, L, e as Error);
forbidden(res, e as Error);
return;
}
}
Expand All @@ -206,7 +205,7 @@ const generatePublicDirectory = (app: TemplatedApp) => {
.end(fs.readFileSync(file).toString('utf-8'));
});
} else {
notFound(res, L, new Error(`File not found: ${file}`));
notFound(res, new Error(`File not found: ${file}`));
}
});
}
Expand Down
143 changes: 98 additions & 45 deletions src/responseUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,118 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import { Logger, type ILogObj } from 'tslog';
import { config } from './cli.js';
import { HttpResponse } from 'uWebSockets.js';

export const forbidden = (res: HttpResponse, LOG: Logger<ILogObj>, e: Error) => {
// from RFC9110
enum HttpStatusCode {
CONTINUE_100 = '100 CONTINUE',
SWITCHING_PROTOCOLS_101 = '101 SWITCHING PROTOCOLS',
PROCESSING_102 = '102 PROCESSING',
EARLY_HINTS_103 = '103 EARLY HINTS',

OK_200 = '200 OK',
CREATED_201 = '201 CREATED',
ACCEPTED_202 = '202 ACCEPTED',
NON_AUTHORITATIVE_INFORMATION_203 = '203 NON AUTHORITATIVE INFORMATION',
NO_CONTENT_204 = '204 NO CONTENT',
RESET_CONTENT_205 = '205 RESET CONTENT',
PARTIAL_CONTENT_206 = '206 PARTIAL CONTENT',

MULTIPLE_CHOICES_300 = '300 MULTIPLE CHOICES',
MOVED_PERMANENTLY_301 = '301 MOVED PERMANENTLY',
FOUND_302 = '302 FOUND',
SEE_OTHER_303 = '303 SEE OTHER',
NOT_MODIFIED_304 = '304 NOT MODIFIED',
USE_PROXY_305 = '305 USE PROXY',
SWITCH_PROXY_306 = '306 SWITCH PROXY',
TEMPORARY_REDIRECT_307 = '307 TEMPORARY REDIRECT',
PERMANENT_REDIRECT_308 = '308 PERMANENT REDIRECT',

BAD_REQUEST_400 = '400 BAD REQUEST',
UNAUTHORIZED_401 = '401 UNAUTHORIZED',
PAYMENT_REQUIRED_402 = '402 PAYMENT REQUIRED',
FORBIDDEN_403 = '403 FORBIDDEN',
NOT_FOUND_404 = '404 NOT FOUND',
METHOD_NOT_ALLOWED_405 = '405 METHOD NOT ALLOWED',
NOT_ACCEPTABLE_406 = '406 NOT ACCEPTABLE',
PROXY_AUTHENTICATION_REQUIRED_407 = '407 PROXY AUTHENTICATION REQUIRED',
REQUEST_TIMEOUT_408 = '408 REQUEST TIMEOUT',
CONFLICT_409 = '409 CONFLICT',
GONE_410 = '410 GONE',
LENGTH_REQUIRED_411 = '411 LENGTH REQUIRED',
PRECONDITION_FAILED_412 = '412 PRECONDITION FAILED',
PAYLOAD_TOO_LARGE_413 = '413 PAYLOAD TOO LARGE',
URI_TOO_LONG_414 = '414 URI TOO LONG',
UNSUPPORTED_MEDIA_TYPE_415 = '415 UNSUPPORTED MEDIA TYPE',
RANGE_NOT_SATISFIABLE_416 = '416 RANGE NOT SATISFIABLE',
EXPECTATION_FAILED_417 = '417 EXPECTATION FAILED',
TEAPOT_418 = '418 TEAPOT',
MISDIRECTED_REQUEST_421 = '421 MISDIRECTED REQUEST',
UNPROCESSABLE_ENTITY_422 = '422 UNPROCESSABLE ENTITY',
LOCKED_423 = '423 LOCKED',
FAILED_DEPENDENCY_424 = '424 FAILED_DEPENDENCY',
TOO_EARLY_425 = '425 TOO EARLY',
UPGRADE_REQUIRED_426 = '426 UPGRADE REQUIRED',
PRECONDITION_REQUIRED_428 = '428 PRECONDITION REQUIRED',
TOO_MANY_REQUESTS_429 = '429 TOO MANY REQUESTS',
REQUEST_HEADER_FIELDS_TOO_LARGE_431 = '431 REQUEST HEADER FIELDS TOO LARGE',
UNAVAILABLE_FOR_LEGAL_REASONS_451 = '451 UNAVAILABLE FOR LEGAL REASONS',

INTERNAL_SERVER_ERROR_500 = '500 INTERNAL SERVER ERROR',
NOT_IMPLEMENTED_501 = '501 NOT IMPLEMENTED',
BAD_GATEWAY_502 = '502 BAD GATEWAY',
SERVICE_UNAVAILABLE_503 = '503 SERVICE UNAVAILABLE',
GATEWAY_TIMEOUT_504 = '504 GATEWAY TIMEOUT',
HTTP_VERSION_NOT_SUPPORTED_505 = '505 HTTP VERSION NOT SUPPORTED',
VARIANT_ALSO_NEGOTIATES_506 = '506 VARIANT ALSO NEGOTIATES',
INSUFFICIENT_STORAGE_507 = '507 INSUFFICIENT STORAGE',
LOOP_DETECTED_508 = '508 LOOP DETECTED',
NOT_EXTENDED_510 = '510 NOT EXTENDED',
NETWORK_AUTHENTICATION_REQUIRED_511 = '511 NETWORK AUTHENTICATION REQUIRED',
}

//

const L = config.logger;

export const httpResponse = (
res: HttpResponse,
statusCode: HttpStatusCode,
msg: string,
error: Error | undefined
) => {
if (res.aborted) return;
LOG.fatal(e.message);
if (error) L.error(error);
res.cork(() => {
res
.writeStatus('403 FORBIDDEN')
.writeStatus(statusCode)
.writeHeader('Content-Type', 'application/json')
.writeHeader('Access-Control-Allow-Origin', '*')
.end('Forbidden');
.end(msg);
});
};

export const notFound = (res: HttpResponse, LOG: Logger<ILogObj>, e: Error) => {
if (res.aborted) return;
LOG.fatal(e.message);
res.cork(() => {
res
.writeStatus('404 NOT FOUND')
.writeHeader('Content-Type', 'application/json')
.writeHeader('Access-Control-Allow-Origin', '*')
.end('Not Found');
});
export const forbidden = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.FORBIDDEN_403, 'Forbidden', e);
};

export const methodNotAllowed = (res: HttpResponse, LOG: Logger<ILogObj>, e: Error) => {
if (res.aborted) return;
LOG.warn(e.message);
res.cork(() => {
res
.writeStatus('405 METHOD NOT ALLOWED')
.writeHeader('Content-Type', 'application/json')
.writeHeader('Access-Control-Allow-Origin', '*')
.end('Method Not Allowed');
});
export const notFound = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.NOT_FOUND_404, 'Not Found', e);
};

export const unprocessableEntity = (res: HttpResponse, LOG: Logger<ILogObj>, e: Error) => {
if (res.aborted) return;
LOG.fatal(e.message);
res.cork(() => {
res
.writeStatus('422 UNPROCESSABLE ENTITY')
.writeHeader('Content-Type', 'application/json')
.writeHeader('Access-Control-Allow-Origin', '*')
.end(e.message);
});
export const methodNotAllowed = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.METHOD_NOT_ALLOWED_405, 'Method Not Allowed', e);
};

export const internalServerError = (res: HttpResponse, LOG: Logger<ILogObj>, e: Error) => {
if (res.aborted) return;
LOG.fatal(e.message);
res.cork(() => {
res
.writeStatus('500 INTERNAL SERVER ERROR')
.writeHeader('Content-Type', 'application/json')
.writeHeader('Access-Control-Allow-Origin', '*')
.end(e.message);
});
export const unsupportedMediaType = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415, 'Unsupported Media Type', e);
};

export const unprocessableEntity = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.UNPROCESSABLE_ENTITY_422, e.message, e);
};

export const internalServerError = (res: HttpResponse, e: Error) => {
httpResponse(res, HttpStatusCode.INTERNAL_SERVER_ERROR_500, e.message, e);
};
97 changes: 64 additions & 33 deletions src/routeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import { App, HttpResponse, TemplatedApp } from 'uWebSockets.js';
import { execute as slangroomChainExecute } from '@dyne/slangroom-chain';

import { reportZenroomError } from './error.js';
import { Endpoints, JSONSchema, Events } from './types.js';
import { Endpoints, JSONSchema, Events, Headers } from './types.js';
import { config } from './cli.js';
import { SlangroomManager } from './slangroom.js';
import { forbidden, methodNotAllowed, notFound, unprocessableEntity } from './responseUtils.js';
import {
forbidden,
methodNotAllowed,
notFound,
unprocessableEntity,
unsupportedMediaType
} from './responseUtils.js';
import { getSchema, validateData, getQueryParams } from './utils.js';
import { template as proctoroom } from './applets.js';

Expand Down Expand Up @@ -105,6 +111,45 @@ export const runPrecondition = async (preconditionPath: string, data: Record<str
await s.execute(zen, { data, keys });
};

const parseDataFunctions = {
'application/json': (data: string) => JSON.parse(data),
'application/x-www-form-urlencoded': (data: string) => {
const res: Record<string, unknown> = {};
decodeURIComponent(data)
.split('&')
.map((r: string) => {
const [k, v] = r.split('=');
res[k] = v;
});
return res;
}
};

const checkAndGetHeaders = (
res: HttpResponse,
req: HttpRequest,
LOG: Logger<ILogObj>,
action: Events,
path: string,
metadata: JSONSchema,
notAllowed: boolean
): Headers | undefined => {
if (action === 'delete') {
notFound(res, new Error(`Not found on ${path}`));
return;
}
if (notAllowed) {
methodNotAllowed(res, new Error(`Post method not allowed on ${path}`));
return;
}
const headers: Headers = {};
headers.request = {};
req.forEach((k, v) => {
headers.request[k] = v;
});
return headers;
};

const execZencodeAndReply = async (
res: HttpResponse,
endpoint: Endpoints,
Expand All @@ -127,28 +172,28 @@ const execZencodeAndReply = async (
}
data['http_headers'] = headers;
} catch (e) {
unprocessableEntity(res, LOG, e as Error);
unprocessableEntity(res, e as Error);
return;
}
}
if (metadata.precondition) {
try {
await runPrecondition(metadata.precondition, data);
} catch (e) {
forbidden(res, LOG, e as Error);
forbidden(res, e as Error);
return;
}
}

try {
validateData(schema, data);
} catch (e) {
unprocessableEntity(res, LOG, e as Error);
unprocessableEntity(res, e as Error);
return;
}

let jsonResult: {
http_headers?: { response?: Record<string, string> }
http_headers?: { response?: Record<string, string> };
} & Record<string, unknown> = {};
try {
if ('chain' in endpoint) {
Expand Down Expand Up @@ -213,19 +258,12 @@ const generatePost = (
) => {
const { path, metadata } = endpoint;
app.post(path, (res, req) => {
if (action === 'delete') {
notFound(res, LOG, new Error(`Not found on ${path}`));
return;
}
if (metadata.disablePost) {
methodNotAllowed(res, LOG, new Error(`Post method not allowed on ${path}`));
const headers = checkAndGetHeaders(res, req, LOG, action, path, metadata, metadata.disablePost);
if (!headers) return;
if (headers.request?.['content-type'] !== metadata.contentType) {
unsupportedMediaType(res, new Error(`Unsupported media type on ${path}`));
return;
}
const headers: Record<string, Record<string, string>> = {};
headers.request = {};
req.forEach((k, v) => {
headers.request[k] = v;
});
/**
* Code may break on `slangroom.execute`
* so it's important to attach the `onAborted` handler before everything else
Expand All @@ -239,7 +277,11 @@ const generatePost = (
if (isLast) {
let data;
try {
data = JSON.parse(
const parseFun = parseDataFunctions[metadata.contentType];
if (!parseFun) {
unsupportedMediaType(res, new Error(`Unsupported media type ${metadata.contentType}`));
}
data = parseFun(
buffer ? Buffer.concat([buffer, chunk]).toString('utf-8') : chunk.toString('utf-8')
);
} catch (e) {
Expand Down Expand Up @@ -272,19 +314,8 @@ const generateGet = (
) => {
const { path, metadata } = endpoint;
app.get(path, async (res, req) => {
if (action === 'delete') {
notFound(res, LOG, new Error(`Not found on ${path}`));
return;
}
if (metadata.disableGet) {
methodNotAllowed(res, LOG, new Error(`Get method not allowed on ${path}`));
return;
}
const headers: Record<string, Record<string, string>> = {};
headers.request = {};
req.forEach((k, v) => {
headers.request[k] = v;
});
const headers = checkAndGetHeaders(res, req, LOG, action, path, metadata, metadata.disableGet);
if (!headers) return;
/**
* Code may break on `slangroom.execute`
* so it's important to attach the `onAborted` handler before everything else
Expand Down Expand Up @@ -352,15 +383,15 @@ export const generateRoute = async (app: TemplatedApp, endpoint: Endpoints, acti

app.get(path + '/raw', (res) => {
if (action === 'delete') {
notFound(res, LOG, new Error(`Not found on ${path}/raw`));
notFound(res, new Error(`Not found on ${path}/raw`));
return;
}
res.writeStatus('200 OK').writeHeader('Content-Type', 'text/plain').end(raw);
});

app.get(path + '/app', async (res) => {
if (action === 'delete') {
notFound(res, LOG, new Error(`Not found on ${path}/app`));
notFound(res, new Error(`Not found on ${path}/app`));
return;
}
const result = _.template(proctoroom)({
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,9 @@ export enum Events {
Add = "add",
Update = "update",
Delete = "delete"
}
}

export type Headers = {
request?: Record<string, string>;
response?: Record<string, string>;
};
Loading

0 comments on commit aaaf48b

Please sign in to comment.