Skip to content

Commit

Permalink
chore: move @neoncitylights/typed-http here
Browse files Browse the repository at this point in the history
  • Loading branch information
neoncitylights committed Dec 16, 2023
1 parent 2d616c7 commit ce892d6
Show file tree
Hide file tree
Showing 20 changed files with 16,960 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A monorepo of pure-TypeScript type packages.
## Packages

- [`@neoncitylights/types`](/packages/types): small library of general-purpose utility types
- [`@neoncitylights/typed-http`](/packages/typed-http): strongly typed HTTP headers, methods, and status codes (supports Fetch, XHR, Node.js HTTP)

## License

Expand Down
77 changes: 77 additions & 0 deletions packages/typed-http/build/conceptTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* All types below are described by the informal JSON schema
* defined by https://webconcepts.info/JSON-concepts
*/

export type Concept = {
/**
* the concept’s name as it is referred to in the source data
*/
concept: string,
/**
* the concept identifier (a URI) which can be used as a URI
* in a browser, and is also used to identify the concept
* in JSON data
*/
id: URL,
/**
* the singular version of the concept’s human-readable name
*/
'name-singular': string,
/**
* the plural version of the concept’s human-readable name
*/
'name-plural': string,
/**
* identifies a registry of all well-known values, if such
* a registry exists
*/
registry?: URL,
/**
* an array of all known values for the concept
*/
values: ConceptValue[],
};

export type ConceptValue = {
/**
* The concept value itself
*/
value: string,
/**
* The identifier of the concept that the value is defined for
*/
concept: URL,
/**
* the value identifier (a URI) which can be used as a URI in
* a browser, and is also used to identify the value in JSON
* data.
*/
id: URL,
/**
* An array of all known descriptions of the value
*/
details: ConceptValueDetail[]
};

export type ConceptValueDetail = {
/**
* A human-readable text snippet that describes the value
*/
description: string,
/**
* a URI identifying the documentation where the concept
* value is defined for
*/
documentation: string,
/**
* the identifier of the specification from which the
* definition and documentation have been harvested
*/
specification: string,
/**
* a short human-readable name for the specification that
* can be used when linking to the documentation
*/
'spec-name': string,
};
11,681 changes: 11,681 additions & 0 deletions packages/typed-http/build/concepts.json

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions packages/typed-http/build/docUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function makeDocTag(annotation: string, value: string): string {
return `@${annotation} ${value}`;
}

export function makeDocSeeTag(label: string, link: URL): string {
return makeDocTag('see', `[${label}](${link})`);
}

export function makeDocBlock(values: string[]): string {
let docBlock = '/**\n';
for(const value of values) {
docBlock += ` *${value === '' ? '\n' : ` ${value}\n`}`;
}
docBlock += ' */';
return docBlock;
}
195 changes: 195 additions & 0 deletions packages/typed-http/build/generateHttpTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import fs from 'fs';
import {
capitalize,
Concept,
ConceptValue,
getDocumentationLabel,
getHttpMethodAsCamelCase,
isForbiddenHttpRequestHeader,
makeCamelCase,
makeDocBlock,
makeDocSeeTag,
makeExcludeType,
makeStringType,
makeType,
makeUnionType,
READ_FILE_PATH,
} from '.';
import wordWrap from 'word-wrap';

function createRunGenerator(
writeFilePath: string,
conceptName: string,
endMessage: (concept: Concept) => string,
generateFn: (concept: Concept, writeStream: fs.WriteStream) => void,
) {
console.time(conceptName);
fs.readFile(READ_FILE_PATH, 'utf8', (err, data) => {
if(err) {
console.error(err);
return;
}

const concepts: Concept[] = JSON.parse(data);
const specificConcept = concepts.find((val) => val['concept'] === conceptName) as Concept;
fs.truncateSync(writeFilePath, 0);
console.log(`${writeFilePath} emptied`);

const writeStream: fs.WriteStream = fs.createWriteStream(writeFilePath, {flags:'a'});
writeStream.write(makeDocBlock([
'This file is generated by the `build/generateHttpTypes.ts` script.',
'To run it, run `npm run generateHttpTypes`.',
'Do NOT edit this file directly.',
]) + '\n\n');

generateFn(specificConcept, writeStream);
console.timeEnd(conceptName);
console.log(`${writeFilePath}: ${endMessage(specificConcept)}\n`);
});
}

function makeFullDocBlock(conceptValue: ConceptValue): string {
const description = wordWrap(conceptValue.details[0].description, { width: 60, indent: '' });
const lines = description.split('\n');

const docLinks: string[] = [];
const specLinks: string[] = [];

for(const detail of conceptValue.details) {
const docLabel = getDocumentationLabel(new URL(detail.documentation));

docLinks.push(makeDocSeeTag(`Documentation${docLabel}`, new URL(detail.documentation)));
specLinks.push(makeDocSeeTag(`Specification → ${detail['spec-name']}`,
new URL(detail.specification)));
}

const docBlock = makeDocBlock([
...lines.map((line) => line.trim()),
'',
...docLinks,
...specLinks,
]);

return docBlock;
}

// Generate HTTP methods
createRunGenerator(
'./src/httpMethods.ts',
'http-method',
(concepts) => `Exported ${concepts.values.length} HTTP methods`,
(concept, writeStream) => {
const httpMethodTypes: string[] = [];

// generate each individual HTTP method type
for(const conceptValue of concept.values) {
const httpMethodType = `HttpMethod${getHttpMethodAsCamelCase(conceptValue.value)}`;
httpMethodTypes.push(httpMethodType);

const httpMethodName = conceptValue.value;
const tsType = makeStringType( httpMethodType, httpMethodName);

const docBlock = makeFullDocBlock(conceptValue);
writeStream.write(`${docBlock}\n${tsType}\n\n`);
}

writeStream.write(makeUnionType('HttpMethod', httpMethodTypes) + '\n');
writeStream.end();
},
);

// Generate HTTP status codes
createRunGenerator(
'./src/httpStatusCodes.ts',
'http-status-code',
(concepts) => `Exported ${concepts.values.length} HTTP status codes`,
(concept, writeStream) => {
const httpStatusCodeTypes: string[] = [];

// categories
const infoStatusCodes: string[] = [];
const successStatusCodes: string[] = [];
const redirectStatusCodes: string[] = [];
const clientErrorStatusCodes: string[] = [];
const serverErrorStatusCodes: string[] = [];

// generate each individual HTTP status code type
for(const conceptValue of concept.values) {
const httpStatusCodeType = `HttpStatusCode${capitalize(makeCamelCase(conceptValue.value))}`;
httpStatusCodeTypes.push(httpStatusCodeType);

const httpStatusCodeName = conceptValue.value;
const tsType = makeType( httpStatusCodeType, httpStatusCodeName, false);

const docBlock = makeFullDocBlock(conceptValue);
writeStream.write(`${docBlock}\n${tsType}\n\n`);

if(httpStatusCodeName.startsWith('1')) {
infoStatusCodes.push(httpStatusCodeType);
}
else if(httpStatusCodeName.startsWith('2')) {
successStatusCodes.push(httpStatusCodeType);
}
else if(httpStatusCodeName.startsWith('3')) {
redirectStatusCodes.push(httpStatusCodeType);
}
else if(httpStatusCodeName.startsWith('4')) {
clientErrorStatusCodes.push(httpStatusCodeType);
}
else if(httpStatusCodeName.startsWith('5')) {
serverErrorStatusCodes.push(httpStatusCodeType);
}
}

// generate union type of all HTTP status codes
writeStream.write(makeUnionType('HttpInfoStatusCode', infoStatusCodes) + '\n\n');
writeStream.write(makeUnionType('HttpSuccessStatusCode', successStatusCodes) + '\n\n');
writeStream.write(makeUnionType('HttpRedirectStatusCode', redirectStatusCodes) + '\n\n');
writeStream.write(makeUnionType('HttpClientErrorStatusCode', clientErrorStatusCodes) + '\n\n');
writeStream.write(makeUnionType('HttpServerErrorStatusCode', serverErrorStatusCodes) + '\n\n');
writeStream.write(makeUnionType('HttpStatusCode', [
'HttpInfoStatusCode',
'HttpSuccessStatusCode',
'HttpRedirectStatusCode',
'HttpClientErrorStatusCode',
'HttpServerErrorStatusCode',
]) + '\n');

writeStream.end();
},
);

// Generate HTTP headers
createRunGenerator(
'./src/httpHeaders.ts',
'http-header',
(concept) => `Exported ${concept.values.length} HTTP headers`,
(concept, writeStream) => {
const httpHeaderTypes: string[] = [];
const forbiddenHttpRequestHeaders: string[] = [];

// generate each individual HTTP header type
for(const conceptValue of concept.values) {
const httpHeaderType = `HttpHeader${capitalize(makeCamelCase(conceptValue.value))}`;
httpHeaderTypes.push(httpHeaderType);

const httpHeaderName = capitalize(conceptValue.value);
const tsType = makeStringType(httpHeaderType, httpHeaderName);
if(isForbiddenHttpRequestHeader(httpHeaderName)) {
forbiddenHttpRequestHeaders.push(httpHeaderType);
}

const docBlock = makeFullDocBlock(conceptValue);
writeStream.write(`${docBlock}\n${tsType}\n\n`);
}

// generate union type of all HTTP headers
writeStream.write(makeUnionType('HttpHeader', httpHeaderTypes) + '\n\n');
writeStream.write(makeUnionType('ForbiddenHttpRequestHeader', forbiddenHttpRequestHeaders) + '\n\n');
writeStream.write(makeUnionType('ForbiddenHttpResponseHeader', [ 'HttpHeaderSetCookie', 'HttpHeaderSetCookie2' ]) + '\n\n');
writeStream.write(makeExcludeType('HttpRequestHeader', 'HttpHeader', 'ForbiddenHttpRequestHeader') + '\n\n');
writeStream.write(makeExcludeType('HttpResponseHeader', 'HttpHeader', 'ForbiddenHttpResponseHeader') + '\n');

writeStream.end();
},
);
5 changes: 5 additions & 0 deletions packages/typed-http/build/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const READ_FILE_PATH = './build/concepts.json';
export * from './conceptTypes';
export * from './docUtils';
export * from './stringUtils';
export * from './typeUtils';
77 changes: 77 additions & 0 deletions packages/typed-http/build/stringUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export function makeCamelCase(str: string): string {
const words = str.split('-');
return words[0] + words.slice(1).map((word) => word[0].toUpperCase() + word.slice(1)).join('');
}

export function capitalize(str: string): string {
return str[0].toUpperCase() + str.slice(1);
}

export function getDocumentationLabel(link: URL) {
const path = link.pathname;

switch(link.hostname) {
case 'datatracker.ietf.org': {
// example URLs:
// - https://datatracker.ietf.org/doc/html/rfc7694#section-3
// - https://datatracker.ietf.org/doc/html/rfc7089#section-2.1.1
const regex = /(draft-(\w|-)+|rfc(\d{4,}))#section-(\d+)((.\d*)*)/;
const ietfPath = path.substring('/doc/html/'.length) + link.hash;
const matches = regex.exec(ietfPath);

if(matches !== null) {
const isDraft: boolean = matches[1].startsWith('draft');
const specType: string = isDraft ? 'Internet Draft' : 'IETF RFC';
const specName: string = isDraft ? matches[1].substring('draft-'.length) : matches[3];
const section: string = matches[4] + matches[5];

return ` → ${specType} ${specName} §${section}`;
}
}
break;
default:
return '';
}
}

export function getHttpMethodAsCamelCase(method: string): string {
switch(method) {
case 'MKACTIVITY': return 'MkActivity';
case 'MKCALENDAR': return 'MkCalendar';
case 'MKCOL': return 'MkCol';
case 'MKREDIRECTREF': return 'MkRedirectRef';
case 'MKWORKSPACE': return 'MkWorkspace';
case 'ORDERPATCH': return 'OrderPatch';
case 'PROPFIND': return 'PropFind';
case 'PROPPATCH': return 'PropPatch';
case 'UPDATEREDIRECTREF': return 'UpdateRedirectRef';
default: return capitalize(makeCamelCase(method.toLowerCase()));
}
}

export function isForbiddenHttpRequestHeader(header: string): boolean {
return header.startsWith('Proxy')
|| header.startsWith('Sec')
|| [
'Accept-Charset',
'Accept-Encoding',
'Access-Control-Request-Headers',
'Access-Control-Request-Method',
'Connection',
'Content-Length',
'Cookie',
'Date',
'DNT',
'Expect',
'Feature-Policy',
'Host',
'Keep-Alive',
'Origin',
'Referer',
'TE',
'Trailer',
'Transfer-Encoding',
'Upgrade',
'Via',
].includes(header);
}
Loading

0 comments on commit ce892d6

Please sign in to comment.