@@ -1,16 +0,0 @@ | |||
import {DataSource} from '../../src/backend/data-source'; | |||
export 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; | |||
}; |
@@ -1,29 +0,0 @@ | |||
export const TEXT_SERIALIZER_PAIR = { | |||
name: 'text/plain', | |||
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 | |||
}; |
@@ -1,75 +0,0 @@ | |||
import { | |||
application, | |||
resource, | |||
validation as v, | |||
} from '../../src/common'; | |||
import { dataSources } from '../../src/backend'; | |||
import {TEXT_SERIALIZER_PAIR} from './serializers'; | |||
import {autoIncrement} from './data-source'; | |||
const Piano = resource(v.object( | |||
{ | |||
brand: v.string() | |||
}, | |||
v.never() | |||
)) | |||
.name('Piano') | |||
.route('pianos') | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete() | |||
.id('id', { | |||
generationStrategy: autoIncrement, | |||
serialize: (id) => id?.toString() ?? '0', | |||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||
schema: v.number(), | |||
}); | |||
const User = resource(v.object( | |||
{ | |||
firstName: v.string(), | |||
middleName: v.string(), | |||
lastName: v.string(), | |||
bio: v.string(), | |||
birthday: v.datelike() | |||
}, | |||
v.never() | |||
)) | |||
.name('User') | |||
.route('users') | |||
.fullText('bio') | |||
.id('id' as const, { | |||
generationStrategy: autoIncrement, | |||
serialize: (id) => id?.toString() ?? '0', | |||
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, | |||
schema: v.number(), | |||
}); | |||
const app = application({ | |||
name: 'piano-service', | |||
}) | |||
.mediaType(TEXT_SERIALIZER_PAIR) | |||
.resource(Piano) | |||
.resource(User); | |||
const backend = app.createBackend({ | |||
dataSource: new dataSources.jsonlFile.DataSource('examples/basic'), | |||
}); | |||
const server = backend.createHttpServer({ | |||
basePath: '/api' | |||
}); | |||
server.listen(3000); | |||
setTimeout(() => { | |||
// Allow user operations after 5 seconds from startup | |||
User | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canPatch(); | |||
}, 5000); |
@@ -1 +0,0 @@ | |||
{"firstName":"John","middleName":"Smith","lastName":"Doe","bio":"This is my updated bio!","birthday":"1986-04-20T00:00:00.000Z","id":1} |
@@ -261,7 +261,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
const theBodyStr = encodingPair.decode(theBodyBuffer); | |||
const theBody = deserializerPair.deserialize(theBodyStr); | |||
try { | |||
// for validation, I wonder why an empty object is returned for PATCH when both methods are enabled | |||
req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false}); | |||
req.body = theBody; | |||
} catch (errRaw) { | |||
const err = errRaw as v.ValiError; | |||
// todo use error message key for each method | |||
@@ -432,11 +434,12 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse<RequestContext>) => { | |||
const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource | |||
const language = plainReq.cn.language ?? plainReq.backend.cn.language; | |||
const mediaType = plainReq.cn.mediaType ?? plainReq.backend.cn.mediaType; | |||
const charset = plainReq.cn.charset ?? plainReq.backend.cn.charset; | |||
if (typeof plainReq.resource !== 'undefined') { | |||
const resourceReq = plainReq as ResourceRequestContext; | |||
const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; | |||
const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; | |||
const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; | |||
// TODO custom middlewares | |||
const effectiveMiddlewares = ( | |||
@@ -451,6 +454,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
try { | |||
middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this | |||
} catch (processRequestErrRaw) { | |||
// TODO add error handlers | |||
handleMiddlewareError(processRequestErrRaw as Error)(resourceReq, res); | |||
return; | |||
} | |||
@@ -528,28 +532,28 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr | |||
} | |||
if (middlewares.length > 0) { | |||
res.statusMessage = resourceReq.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, | |||
res.statusMessage = language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, | |||
resourceReq.resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { | |||
Allow: middlewares.map((m) => m.method).join(', '), | |||
'Content-Language': resourceReq.backend.cn.language.name, | |||
'Content-Language': language.name, | |||
}); | |||
res.end(); | |||
return; | |||
} | |||
res.statusMessage = resourceReq.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, | |||
res.statusMessage = language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, | |||
resourceReq.resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { | |||
'Content-Language': resourceReq.backend.cn.language.name, | |||
'Content-Language': language.name, | |||
}); | |||
res.end(); | |||
return; | |||
} | |||
res.statusMessage = reqRaw.backend.cn.language.statusMessages.notImplemented.replace(/\$RESOURCE/g, | |||
res.statusMessage = language.statusMessages.notImplemented.replace(/\$RESOURCE/g, | |||
reqRaw.resource!.state.itemName) ?? ''; | |||
res.writeHead(constants.HTTP_STATUS_NOT_IMPLEMENTED, { | |||
'Content-Language': reqRaw.backend.cn.language.name, | |||
'Content-Language': language.name, | |||
}); | |||
res.end(); | |||
}; | |||
@@ -176,7 +176,7 @@ export const applyDelta = async <T extends v.BaseSchema = v.BaseSchema>( | |||
} | |||
mutateObject(mutablePreviousObject, deltaItem, pathSchema); | |||
if (!v.is(resourceObjectSchema, mutablePreviousObject)) { | |||
if (!v.is(resourceObjectSchema, mutablePreviousObject, { skipPipe: true })) { | |||
throw new InvalidOperationError(); | |||
} | |||
@@ -27,6 +27,7 @@ export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path' | |||
...etcAdditionalHeaders | |||
} = additionalHeaders; | |||
// odd that request() uses OutgoingHttpHeaders instead of IncomingHttpHeaders... | |||
const headers: OutgoingHttpHeaders = { | |||
...(options.headers ?? {}), | |||
...etcAdditionalHeaders, | |||
@@ -1,5 +1,5 @@ | |||
{ | |||
"name": "data-source-file-jsonl", | |||
"name": "@modal-sh/yasumi-data-source-file-jsonl", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
@@ -45,5 +45,22 @@ | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "public" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
"require": "./dist/cjs/development/index.js", | |||
"import": "./dist/esm/development/index.js" | |||
}, | |||
"require": "./dist/cjs/production/index.js", | |||
"import": "./dist/esm/production/index.js", | |||
"types": "./dist/types/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
} | |||
} |
@@ -0,0 +1,108 @@ | |||
# 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 |
@@ -0,0 +1,18 @@ | |||
meta { | |||
name: Create Post | |||
type: http | |||
seq: 2 | |||
} | |||
post { | |||
url: http://localhost:6969/api/posts | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"title": "New Post", | |||
"content": "Hello there" | |||
} | |||
} |
@@ -0,0 +1,11 @@ | |||
meta { | |||
name: Delete Post | |||
type: http | |||
seq: 8 | |||
} | |||
delete { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: none | |||
auth: none | |||
} |
@@ -0,0 +1,11 @@ | |||
meta { | |||
name: Get Single Post | |||
type: http | |||
seq: 3 | |||
} | |||
get { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: none | |||
auth: none | |||
} |
@@ -0,0 +1,25 @@ | |||
meta { | |||
name: Modify Post (Delta) | |||
type: http | |||
seq: 5 | |||
} | |||
patch { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/json-patch+json | |||
} | |||
body:json { | |||
[ | |||
{ | |||
"op": "replace", | |||
"path": "content", | |||
"value": "I changed the value via delta." | |||
} | |||
] | |||
} |
@@ -0,0 +1,23 @@ | |||
meta { | |||
name: Modify Post (Merge) | |||
type: http | |||
seq: 4 | |||
} | |||
patch { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: json | |||
auth: none | |||
} | |||
headers { | |||
Content-Type: application/merge-patch+json | |||
} | |||
body:json { | |||
{ | |||
"title": "Modified Post", | |||
"content": "I changed the content via merge." | |||
} | |||
} |
@@ -0,0 +1,11 @@ | |||
meta { | |||
name: Query Posts | |||
type: http | |||
seq: 1 | |||
} | |||
get { | |||
url: http://localhost:6969/api/posts | |||
body: none | |||
auth: none | |||
} |
@@ -0,0 +1,19 @@ | |||
meta { | |||
name: Replace Post | |||
type: http | |||
seq: 7 | |||
} | |||
put { | |||
url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 | |||
body: json | |||
auth: none | |||
} | |||
body:json { | |||
{ | |||
"title": "Replaced Post", | |||
"content": "The old content is gone.", | |||
"id": "5fac64d6-d261-42bb-a67b-bc7e1955a7e2" | |||
} | |||
} |
@@ -0,0 +1,13 @@ | |||
{ | |||
"version": "1", | |||
"name": "cms-web-api", | |||
"type": "collection", | |||
"ignore": [ | |||
"node_modules", | |||
".git" | |||
], | |||
"presets": { | |||
"requestType": "http", | |||
"requestUrl": "http://localhost:6969/api/" | |||
} | |||
} |
@@ -0,0 +1,50 @@ | |||
{ | |||
"name": "@modal-sh/yasumi-example-cms-web-api", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=16" | |||
}, | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.11.0", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.3.3", | |||
"vitest": "^1.2.0" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/yasumi": "*", | |||
"@modal-sh/yasumi-data-source-file-jsonl": "*", | |||
"tsx": "^4.7.1" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "tsx src/index.ts", | |||
"dev": "tsx watch src/index.ts", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "CMS example service for Yasumi", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} |
@@ -0,0 +1,67 @@ | |||
import { application, resource, validation as v } from '@modal-sh/yasumi'; | |||
import { randomUUID } from 'crypto'; | |||
import {JsonLinesDataSource} from '@modal-sh/yasumi-data-source-file-jsonl'; | |||
const User = resource( | |||
v.object({ | |||
email: v.string([v.email()]), | |||
password: v.string(), | |||
createdAt: v.datelike(), | |||
updatedAt: v.datelike(), | |||
}) | |||
) | |||
.name('User') | |||
.route('users') | |||
.id('id', { | |||
generationStrategy: () => Promise.resolve(randomUUID()), | |||
schema: v.string(), | |||
serialize: (v: unknown) => v?.toString() ?? '', | |||
deserialize: (v: string) => v, | |||
}) | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const now = () => new Date(); | |||
const Post = resource( | |||
v.object({ | |||
title: v.string(), | |||
content: v.string(), | |||
createdAt: v.optional(v.datelike(), now), | |||
updatedAt: v.optional(v.datelike(), now), | |||
}) | |||
) | |||
.name('Post') | |||
.route('posts') | |||
.id('id', { | |||
generationStrategy: () => Promise.resolve(randomUUID()), | |||
schema: v.string(), | |||
serialize: (v: unknown) => v?.toString() ?? '', | |||
deserialize: (v: string) => v, | |||
}) | |||
.canFetchItem() | |||
.canFetchCollection() | |||
.canCreate() | |||
.canEmplace() | |||
.canPatch() | |||
.canDelete(); | |||
const app = application({ | |||
name: 'cms' | |||
}) | |||
.resource(User) | |||
.resource(Post); | |||
const backend = app.createBackend({ | |||
dataSource: new JsonLinesDataSource(), | |||
}); | |||
const server = backend.createHttpServer({ | |||
basePath: '/api', | |||
}); | |||
server.listen(6969); |
@@ -0,0 +1,8 @@ | |||
import { describe, it, expect } from 'vitest'; | |||
import add from '../src'; | |||
describe('blah', () => { | |||
it('works', () => { | |||
expect(add(1, 1)).toEqual(2); | |||
}); | |||
}); |
@@ -0,0 +1,23 @@ | |||
{ | |||
"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 | |||
} | |||
} |
@@ -59,6 +59,34 @@ importers: | |||
specifier: ^1.2.0 | |||
version: 1.5.0(@types/node@20.12.7) | |||
packages/examples/cms-web-api: | |||
dependencies: | |||
'@modal-sh/yasumi': | |||
specifier: '*' | |||
version: link:../../core | |||
'@modal-sh/yasumi-data-source-file-jsonl': | |||
specifier: '*' | |||
version: link:../../data-sources/file-jsonl | |||
tsx: | |||
specifier: ^4.7.1 | |||
version: 4.7.2 | |||
devDependencies: | |||
'@types/node': | |||
specifier: ^20.11.0 | |||
version: 20.12.7 | |||
pridepack: | |||
specifier: 2.6.0 | |||
version: 2.6.0(tslib@2.6.2)(typescript@5.4.5) | |||
tslib: | |||
specifier: ^2.6.2 | |||
version: 2.6.2 | |||
typescript: | |||
specifier: ^5.3.3 | |||
version: 5.4.5 | |||
vitest: | |||
specifier: ^1.2.0 | |||
version: 1.5.0(@types/node@20.12.7) | |||
packages: | |||
/@esbuild/aix-ppc64@0.19.12: | |||