Browse Source

Initial commit

Add files from pridepack. Initial implementation.
master
TheoryOfNekomata 8 months ago
commit
d3665c5d6d
13 changed files with 2242 additions and 0 deletions
  1. +109
    -0
      .gitignore
  2. +7
    -0
      LICENSE
  3. +53
    -0
      package.json
  4. +1519
    -0
      pnpm-lock.yaml
  5. +3
    -0
      pridepack.json
  6. +323
    -0
      src/core.ts
  7. +100
    -0
      src/data-sources/file-jsonl.ts
  8. +1
    -0
      src/data-sources/index.ts
  9. +2
    -0
      src/index.ts
  10. +3
    -0
      src/serializers/application/json.ts
  11. +7
    -0
      src/serializers/index.ts
  12. +90
    -0
      src/server.ts
  13. +25
    -0
      tsconfig.json

+ 109
- 0
.gitignore View File

@@ -0,0 +1,109 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.production
.env.development

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

.npmrc
*.jsonl
.idea/

+ 7
- 0
LICENSE View File

@@ -0,0 +1,7 @@
MIT License Copyright (c) 2024 TheoryOfNekomata <allan.crisostomo@outlook.com>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 53
- 0
package.json View File

@@ -0,0 +1,53 @@
{
"name": "yasumi",
"version": "0.0.0",
"files": [
"dist",
"src"
],
"engines": {
"node": ">=16"
},
"license": "MIT",
"keywords": [
"pridepack"
],
"devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20.11.0",
"pridepack": "2.6.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"scripts": {
"prepublishOnly": "pridepack clean && pridepack build",
"build": "pridepack build",
"type-check": "pridepack check",
"clean": "pridepack clean",
"watch": "pridepack watch",
"start": "pridepack start",
"dev": "pridepack dev",
"test": "vitest"
},
"private": false,
"description": "HATEOAS-first backend framework",
"repository": {
"url": "",
"type": "git"
},
"homepage": "",
"bugs": {
"url": ""
},
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"publishConfig": {
"access": "public"
},
"dependencies": {
"inflection": "^3.0.0",
"negotiator": "^0.6.3",
"tsx": "^4.7.1",
"valibot": "^0.30.0"
}
}

+ 1519
- 0
pnpm-lock.yaml
File diff suppressed because it is too large
View File


+ 3
- 0
pridepack.json View File

@@ -0,0 +1,3 @@
{
"target": "es2018"
}

+ 323
- 0
src/core.ts View File

@@ -0,0 +1,323 @@
import { pluralize } from 'inflection';
import { constants } from 'http2';
import { IncomingMessage, ServerResponse } from 'http';
import * as v from 'valibot';
import Negotiator from 'negotiator';
import {SerializerPair} from './serializers';

export interface DataSource<T = object> {
initialize(): Promise<unknown>;
getMultiple(): Promise<T[]>;
getSingle(id: string): Promise<T | null>;
create(data: Partial<T>): Promise<T>;
delete(id: string): Promise<unknown>;
emplace(id: string, data: Partial<T>): Promise<T>;
patch(id: string, data: Partial<T>): Promise<T | null>;
}

export interface ApplicationParams {
name: string;
dataSource?: (resource: Resource) => DataSource;
}

export interface Resource {
idAttr: string;
itemName?: string;
collectionName?: string;
routeName?: string;
dataSource: DataSource;
newId(dataSource: DataSource): string | number | unknown;
}

interface GenerationStrategy {
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>;
}

interface IdParams {
generationStrategy: GenerationStrategy;
}

export const resource = (schema: Parameters<typeof v.object>[0]) => {
let theIdAttr: string;
let theItemName: string;
let theCollectionName: string;
let theRouteName: string;
let idGenerationStrategy: GenerationStrategy;
const fullTextAttrs = new Set<string>();

return {
id(newIdAttr: string, params: IdParams) {
theIdAttr = newIdAttr;
idGenerationStrategy = params.generationStrategy;
return this;
},
newId(dataSource: DataSource) {
return idGenerationStrategy(dataSource);
},
fullText(fullTextAttr: string) {
if (schema[fullTextAttr]?.type === 'string') {
fullTextAttrs.add(fullTextAttr);
return this;
}

throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`);
},
name(n: string) {
theItemName = n;
theCollectionName = theCollectionName ?? pluralize(theItemName).toLowerCase();
theRouteName = theRouteName ?? theCollectionName;
return this;
},
collection(n: string) {
theCollectionName = n;
theRouteName = theRouteName ?? theCollectionName;
return this;
},
route(n: string) {
theRouteName = n;
return this;
},
get idAttr() {
return theIdAttr;
},
get collectionName() {
return theCollectionName;
},
get itemName() {
return theItemName;
},
get routeName() {
return theRouteName;
}
};
};

interface CreateServerParams {
baseUrl?: string;
host?: string;
}

const handleGetAll = async (serializerPair: SerializerPair, mediaType: string, dataSource: DataSource, res: ServerResponse) => {
const resData = await dataSource.getMultiple(); // TODO paginated responses per resource
const theFormatted = serializerPair.serialize(resData);

res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': mediaType,
'X-Resource-Total-Item-Count': resData.length
});
res.end(theFormatted);
};

const handleGetSingle = async (serializerPair: SerializerPair, mediaType: string, resource: Resource, mainResourceId: string, dataSource: DataSource, res: ServerResponse) => {
const singleResDatum = await dataSource.getSingle(mainResourceId);

if (singleResDatum) {
const theFormatted = serializerPair.serialize(singleResDatum);
res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': mediaType });
res.end(theFormatted);
return;
}

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = `${resource.itemName} Not Found`;
res.end();
return;
};

const handleCreate = async (
deserializer: SerializerPair,
serializer: SerializerPair,
mediaType: string,
resource: Resource,
dataSource: DataSource,
req: IncomingMessage,
res: ServerResponse
) => {
return new Promise<void>((resolve) => {
let body = Buffer.from('');
req.on('data', (chunk) => {
body = Buffer.concat([body, chunk]);
});
req.on('end', async () => {
const bodyStr = body.toString('utf-8'); // TODO use encoding in request header
let bodyDeserialized: object;
try {
bodyDeserialized = deserializer.deserialize(bodyStr);
if (typeof bodyDeserialized !== 'object' || bodyDeserialized === null) {
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = `Invalid ${resource.itemName}`;
res.end();
resolve();
return;
}
} catch {
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.statusMessage = `Invalid ${resource.itemName}`;
res.end();
resolve();
return;
}

try {
const newId = await resource.newId(dataSource);
const newObject = await dataSource.create({
...bodyDeserialized,
[resource.idAttr]: newId,
});
const theFormatted = serializer.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': mediaType});
res.end(theFormatted);
return;
} catch {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.statusMessage = `Could Not Return ${resource.itemName}`;
res.end();
}

resolve();
});
});
};

export const application = (appParams: ApplicationParams) => {
const resources = new Set<Resource>();
const serializers = new Map<string, SerializerPair>();

return {
contentType(mimeTypePrefix: string, serializerPair: SerializerPair) {
serializers.set(mimeTypePrefix, serializerPair);
return this;
},
resource(res: Partial<Resource>) {
res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource);
if (typeof res.dataSource === 'undefined') {
throw new Error(`Resource ${res.itemName} must have a data source.`);
}
resources.add(res as Resource);
return this;
},
async createServer(serverParams = {} as CreateServerParams) {
const {
baseUrl = '/',
host = 'http://localhost' // TODO not a sensible default...
} = serverParams;

const serverModule = await import('http');
return serverModule.createServer(
async (req, res) => {
if (!req.method) {
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, {
'Allow': 'HEAD,GET,POST,PUT,PATCH,DELETE' // TODO check with resources on allowed methods
});
res.end();
return;
}

if (!req.url) {
res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.end();
return;
}

const urlObject = new URL(req.url, host);
const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl.length);
const urlWithoutBase = urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw;

if (req.method.toUpperCase() === 'GET' && urlWithoutBase === '/') {
const data = {
name: appParams.name
};
res.writeHead(constants.HTTP_STATUS_OK, {
'Content-Type': 'application/json', // TODO content negotiation,
// we are using custom headers for links because the standard Link header
// is referring to the document metadata (e.g. author, next page, etc)
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
'X-Resource-Link': Array.from(resources)
.map((r) =>
`<${baseUrl}/${r.routeName}>; name="${r.collectionName}"`,
// TODO add host?
)
.join(', ')
});
res.end(JSON.stringify(data))
return;
}

const [, mainResourceRouteName, mainResourceId = ''] = urlWithoutBase.split('/');
const theResource = Array.from(resources).find((r) => r.routeName === mainResourceRouteName);
if (typeof theResource !== 'undefined') {
await theResource.dataSource.initialize();
const method = req.method.toUpperCase();
if (method === 'GET') {
const negotiator = new Negotiator(req);
const availableMediaTypes = Array.from(serializers.keys());
const theMediaType = negotiator.mediaType(availableMediaTypes);

if (typeof theMediaType === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const theSerializerPair = serializers.get(theMediaType);
if (typeof theSerializerPair === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

if (mainResourceId === '') {
await handleGetAll(theSerializerPair, theMediaType, theResource.dataSource, res);
return;
}

await handleGetSingle(theSerializerPair, theMediaType, theResource, mainResourceId, theResource.dataSource, res);
return;
}

if (method === 'POST') {
if (mainResourceId === '') {
const theDeserializer = serializers.get(req.headers['content-type'] ?? 'application/octet-stream');
if (typeof theDeserializer === 'undefined') {
res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE;
res.end();
return;
}

const negotiator = new Negotiator(req);
const availableMediaTypes = Array.from(serializers.keys());
const theMediaType = negotiator.mediaType(availableMediaTypes);

if (typeof theMediaType === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

const theSerializer = serializers.get(theMediaType);
if (typeof theSerializer === 'undefined') {
res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE;
res.end();
return;
}

await handleCreate(theDeserializer, theSerializer, theMediaType, theResource, theResource.dataSource, req, res);
return;
}

res.statusCode = constants.HTTP_STATUS_BAD_REQUEST;
res.end();
return;
}

return;
}

res.statusCode = constants.HTTP_STATUS_NOT_FOUND;
res.statusMessage = 'URL Not Found';
res.end();
}
);
}
};
};

+ 100
- 0
src/data-sources/file-jsonl.ts View File

@@ -0,0 +1,100 @@
import {readFile, writeFile} from 'fs/promises';
import {DataSource as DataSourceInterface, Resource} from '../core';

export class DataSource<T extends Record<string, string>> implements DataSourceInterface<T> {
private readonly path: string;

data: T[] = [];

constructor(private readonly resource: Resource) {
this.path = `${this.resource.collectionName}.jsonl`;
}

async initialize() {
try {
const fileContents = await readFile(this.path, 'utf-8');
const lines = fileContents.split('\n');
this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
} catch (err) {
await writeFile(this.path, '');
}
}

async getMultiple() {
return [...this.data];
}

async getSingle(id: string) {
const foundData = this.data.find((s) => s[this.resource.idAttr as string].toString() === id);

if (foundData) {
return {
...foundData
};
}

return null;
}

async create(data: Partial<T>) {
const newData = [
...this.data,
data
];

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

return data as T;
}

async delete(id: string) {
const newData = this.data.filter((s) => !(s[this.resource.idAttr as string].toString() === id));

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));
}

async emplace(id: string, data: Partial<T>) {
const existing = await this.getSingle(id);

if (existing) {
const newData = this.data.map((d) => {
if (d[this.resource.idAttr as string].toString() === id) {
return data;
}

return d;
});

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

return data as T;
}

return this.create(data);
}

async patch(id: string, data: Partial<T>) {
const existing = await this.getSingle(id);

if (!existing) {
return null;
}

const newItem = {
...existing,
...data,
}

const newData = this.data.map((d) => {
if (d[this.resource.idAttr as string].toString() === id) {
return newItem;
}

return d;
});

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));

return newItem as T;
}
}

+ 1
- 0
src/data-sources/index.ts View File

@@ -0,0 +1 @@
export * as jsonlFile from './file-jsonl';

+ 2
- 0
src/index.ts View File

@@ -0,0 +1,2 @@
export * from './core';
export * from './data-sources';

+ 3
- 0
src/serializers/application/json.ts View File

@@ -0,0 +1,3 @@
export const serialize = (obj: unknown) => JSON.stringify(obj);

export const deserialize = (str: string) => JSON.parse(str);

+ 7
- 0
src/serializers/index.ts View File

@@ -0,0 +1,7 @@
export * as applicationJson from './application/json';
export * as textJson from './application/json';

export interface SerializerPair {
serialize: <T>(object: T) => string;
deserialize: <T>(s: string) => T;
}

+ 90
- 0
src/server.ts View File

@@ -0,0 +1,90 @@
import {
application,
DataSource,
Resource,
resource,
} from '.';

import * as v from 'valibot';
import * as dataSources from './data-sources';
import * as serializers from './serializers';

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
);

return (highestId + 1).toString();
};


const TEXT_SERIALIZER_PAIR = {
serialize(obj: unknown, level = 0): string {
if (Array.isArray(obj)) {
return obj.map((o) => this.serialize(o)).join('\n\n');
}

if (typeof obj === 'object') {
if (obj !== null) {
return Object.entries(obj)
.map(([key, value]) => `${Array(level * 2).fill(' ').join('')}${key}: ${this.serialize(value, level + 1)}`)
.join('\n');
}

return '';
}

if (typeof obj === 'number' && Number.isFinite(obj)) {
return obj.toString();
}

if (typeof obj === 'string') {
return obj;
}

return '';
},
deserialize: <T>(str: string) => str as T
};

const Piano = resource({
brand: v.string()
})
.name('Piano')
.id('id', {
generationStrategy: autoIncrement,
});

// TODO implement authentication and RBAC on each resource

const User = resource({
firstName: v.string(),
middleName: v.string(),
lastName: v.string(),
bio: v.string(),
createdAt: v.date()
})
.name('User')
.fullText('bio')
.id('id', {
generationStrategy: autoIncrement,
});

const app = application({
name: 'piano-service',
dataSource: (resource: Resource) => new dataSources.jsonlFile.DataSource(resource),
})
.contentType('application/json', serializers.applicationJson)
.contentType('text/json', serializers.textJson)
.contentType('text/plain', TEXT_SERIALIZER_PAIR)
.resource(Piano)
.resource(User);

app.createServer({
baseUrl: '/api'
}).then((server) => {
server.listen(3000);
});

+ 25
- 0
tsconfig.json View File

@@ -0,0 +1,25 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018",
"useDefineForClassFields": false,
"declarationMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

Loading…
Cancel
Save