Implemented server routes

This commit is contained in:
Sebastian Seedorf
2022-08-17 23:27:19 +02:00
parent 9660f0cf34
commit 92b762bbd2
27 changed files with 2754 additions and 1692 deletions

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
.cache
public
static
coverage

18
.prettierrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "avoid",
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "none",
"overrides": [
{
"files": "*.css",
"options": {
"singleQuote": false,
"tabWidth": 4
}
}
]
}

View File

@@ -1,9 +1,10 @@
import {createContext, FC, useCallback, useContext, useMemo, useState} from "react"; import {createContext, FC, useCallback, useContext, useEffect, useMemo, useState} from "react";
import {Group} from "../../src/types"; import {Group} from "../../src/types";
import {useLocalStorage} from "../../src/hooks/useLocalStorage";
import {ReactNodeLike} from "prop-types"; import {ReactNodeLike} from "prop-types";
import pako from "pako"; import pako from "pako";
import {Dict} from "../../src/types"; import {Dict} from "../../src/types";
import {fixedEncodeURIComponent} from "../../src/utils";
import {GroupRenameBody, GroupSetFactoryArrayBody, SetFactoryArrayBody} from "../../src/types/ApiSchemasFrontend";
interface Props { interface Props {
children: ReactNodeLike children: ReactNodeLike
@@ -68,9 +69,20 @@ interface StoredFile {
excludedSuggestions: string[] excludedSuggestions: string[]
} }
export const GroupProvider: FC<Props> = ({children, initial}) => { export const postFetchJson = async (url: string, body: Dict<unknown>) => {
const [excludedSuggestions, setExcludedSuggestions] = useState<string[]>(initial.ignored) const res = await fetch(url, {
const [basicValues, setBasicValues] = useState<string[]>(initial.base) method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
return res.json()
}
export const GroupProvider: FC<Props> = ({children, id, initial}) => {
const [excludedSuggestions, _setExcludedSuggestions] = useState<string[]>(initial.ignored)
const [basicValues, _setBasicValues] = useState<string[]>(initial.base)
const [groups, setGroups] = useState<Dict<Group>>(initial.groups) const [groups, setGroups] = useState<Dict<Group>>(initial.groups)
const doNotSuggest = useMemo<Set<string>>(() => { const doNotSuggest = useMemo<Set<string>>(() => {
@@ -81,35 +93,85 @@ export const GroupProvider: FC<Props> = ({children, initial}) => {
return new Set([...Object.values(groups).flatMap(group => [...group.exports])]) return new Set([...Object.values(groups).flatMap(group => [...group.exports])])
}, [groups]) }, [groups])
const setExcludedSuggestions = useCallback<typeof _setExcludedSuggestions>((val) => {
_setExcludedSuggestions(val)
postFetchJson(`/api/${fixedEncodeURIComponent(id)}/factories`, {
type: 'ignored',
factories: val
} as SetFactoryArrayBody)
.catch(console.error)
}, [id])
const setBasicValues = useCallback<typeof _setBasicValues>((val) => {
_setBasicValues(val)
postFetchJson(`/api/${fixedEncodeURIComponent(id)}/factories`, {
type: 'base',
factories: val
} as SetFactoryArrayBody)
.catch(console.error)
}, [id])
const addGroup = useCallback((name: string, exports: string[] = [], malls: string[] = []) => { const addGroup = useCallback((name: string, exports: string[] = [], malls: string[] = []) => {
name = name.replace(/[.$]/g, '')
if (name in groups) return false if (name in groups) return false
setGroups(groups => { setGroups(groups => {
groups[name] = { name, exports, malls } groups[name] = { name, exports, malls }
return groups return {...groups}
}) })
;(async () => {
await postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/add`, {})
if (exports.length) {
await postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/factories`, {
type: 'exports',
factories: exports
} as GroupSetFactoryArrayBody)
}
if (malls.length) {
await postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/factories`, {
type: 'malls',
factories: exports
} as GroupSetFactoryArrayBody)
}
})().catch(console.error)
return true return true
}, [groups, setGroups]) }, [groups, id])
const removeGroup = useCallback((name: string) => { const removeGroup = useCallback((name: string) => {
name = name.replace(/[.$]/g, '')
setGroups(groups => { setGroups(groups => {
delete groups[name] delete groups[name]
return groups console.log(groups[name])
return {...groups}
}) })
}, [setGroups]) postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/remove`, {})
.catch(console.error)
}, [id])
const renameGroup = useCallback((name: string, newName: string) => { const renameGroup = useCallback((name: string, newName: string) => {
if (name === newName) return name = name.replace(/[.$]/g, '')
newName = newName.replace(/[.$]/g, '')
if (newName in groups) return
setGroups(groups => { setGroups(groups => {
groups[newName] = {...groups[name], name: newName} groups[newName] = {...groups[name], name: newName}
delete groups[name] delete groups[name]
return groups return {...groups}
}) })
}, [setGroups]) postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/rename`, {
newName
} as GroupRenameBody)
.catch(console.error)
}, [groups, id])
const setFactories = useCallback((name: string, factories: string[], type: 'inputs'|'intermediates'|'exports'|'malls') => { const setFactories = useCallback((name: string, factories: string[], type: 'exports'|'malls') => {
name = name.replace(/[.$]/g, '')
setGroups(groups => { setGroups(groups => {
groups[name] = {...groups[name], [type]: factories} groups[name] = {...groups[name], [type]: factories}
return groups return {...groups}
}) })
}, [setGroups]) postFetchJson(`/api/${fixedEncodeURIComponent(id)}/group/${fixedEncodeURIComponent(name)}/factories`, {
type,
factories
} as GroupSetFactoryArrayBody)
.catch(console.error)
}, [id])
const getInputType = useCallback((uid: string) => { const getInputType = useCallback((uid: string) => {
if (basicValues.includes(uid)) return 'base' if (basicValues.includes(uid)) return 'base'
else if (exportedFactories.has(uid)) return 'produced' else if (exportedFactories.has(uid)) return 'produced'
@@ -133,7 +195,7 @@ export const GroupProvider: FC<Props> = ({children, initial}) => {
setGroups(value.groups) setGroups(value.groups)
setBasicValues(value.basicValues) setBasicValues(value.basicValues)
setExcludedSuggestions(value.excludedSuggestions) setExcludedSuggestions(value.excludedSuggestions)
}, [setBasicValues, setExcludedSuggestions, setGroups]) }, [])
const value: GroupContextType = useMemo(() => ({ const value: GroupContextType = useMemo(() => ({
doNotSuggest, doNotSuggest,

View File

@@ -25,6 +25,7 @@ const FactorySelectBase: FC<Props> = ({id, factories, onSetFactories}) => {
return <Select return <Select
id={id} id={id}
instanceId={id}
value={state} value={state}
components={{ components={{
MultiValueLabel: ({data, innerProps}) => ( MultiValueLabel: ({data, innerProps}) => (

View File

@@ -10,6 +10,9 @@ const nextConfig = {
MONGO_DB: envVar.get('MONGO_DB').required().asString(), MONGO_DB: envVar.get('MONGO_DB').required().asString(),
MONGO_USER: envVar.get('MONGO_USER').required().asString(), MONGO_USER: envVar.get('MONGO_USER').required().asString(),
MONGO_PASS: envVar.get('MONGO_PASS').required().asString() MONGO_PASS: envVar.get('MONGO_PASS').required().asString()
},
publicRuntimeConfig: {
TENANT_TYPE: envVar.get('TENANT_TYPE').asString()
} }
} }

View File

@@ -12,6 +12,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"deepcopy": "^2.1.0", "deepcopy": "^2.1.0",
"env-var": "^7.1.1", "env-var": "^7.1.1",
"jsonschema": "^1.4.1",
"mongodb": "^4.8.1", "mongodb": "^4.8.1",
"next": "12.2.4", "next": "12.2.4",
"next-superjson-plugin": "^0.3.0", "next-superjson-plugin": "^0.3.0",
@@ -31,6 +32,7 @@
"@types/seedrandom": "^3.0.2", "@types/seedrandom": "^3.0.2",
"eslint": "8.21.0", "eslint": "8.21.0",
"eslint-config-next": "12.2.4", "eslint-config-next": "12.2.4",
"json-schema-to-typescript": "^11.0.2",
"typescript": "4.7.4" "typescript": "4.7.4"
} }
} }

View File

@@ -0,0 +1,17 @@
import {nextHandler} from "../../../src/utils/errors";
import {validate} from "../../../src/validation";
import {IdParam, SetFactoryArrayBody} from "../../../src/types/ApiSchemas";
import {setFactories} from "../../../src/database/groups";
import {waitForInitSchemas} from "../../../src/validation/schemas";
const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const { transformed: params } = validate<IdParam>(req.query, '/IdParam')
const { transformed: body } = validate<SetFactoryArrayBody>(req.body, '/SetFactoryArrayBody')
const success = await setFactories(params.id, body.type, body.factories)
res.json({ success })
})
export default handler

View File

@@ -0,0 +1,16 @@
import {nextHandler} from "../../../../../src/utils/errors";
import {validate} from "../../../../../src/validation";
import {GroupIdParam} from "../../../../../src/types/ApiSchemas";
import {addGroup} from "../../../../../src/database/groups";
import {waitForInitSchemas} from "../../../../../src/validation/schemas";
const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const { transformed: params } = validate<GroupIdParam>(req.query, '/GroupIdParam')
const success = await addGroup(params.id, params.name)
res.json({ success })
})
export default handler

View File

@@ -0,0 +1,17 @@
import {nextHandler} from "../../../../../src/utils/errors";
import {validate} from "../../../../../src/validation";
import {GroupIdParam, GroupSetFactoryArrayBody} from "../../../../../src/types/ApiSchemas";
import {setFactoriesOfGroup} from "../../../../../src/database/groups";
import {waitForInitSchemas} from "../../../../../src/validation/schemas";
const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const { transformed: params } = validate<GroupIdParam>(req.query, '/GroupIdParam')
const { transformed: body } = validate<GroupSetFactoryArrayBody>(req.body, '/GroupSetFactoryArrayBody')
const success = await setFactoriesOfGroup(params.id, params.name, body.type, body.factories)
res.json({ success })
})
export default handler

View File

@@ -0,0 +1,16 @@
import {nextHandler} from "../../../../../src/utils/errors";
import {validate} from "../../../../../src/validation";
import {GroupIdParam} from "../../../../../src/types/ApiSchemas";
import {removeGroup} from "../../../../../src/database/groups";
import {waitForInitSchemas} from "../../../../../src/validation/schemas";
const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const { transformed: params } = validate<GroupIdParam>(req.query, '/GroupIdParam')
const success = await removeGroup(params.id, params.name)
res.json({ success })
})
export default handler

View File

@@ -0,0 +1,17 @@
import {nextHandler} from "../../../../../src/utils/errors";
import {validate} from "../../../../../src/validation";
import {GroupIdParam, GroupRenameBody} from "../../../../../src/types/ApiSchemas";
import {renameGroup} from "../../../../../src/database/groups";
import {waitForInitSchemas} from "../../../../../src/validation/schemas";
const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const { transformed: params } = validate<GroupIdParam>(req.query, '/GroupIdParam')
const { transformed: body } = validate<GroupRenameBody>(req.body, '/GroupRenameBody')
const success = await renameGroup(params.id, params.name, body.newName)
res.json({ success })
})
export default handler

15
pages/api/dev/schemas.ts Normal file
View File

@@ -0,0 +1,15 @@
import {GroupData, setGroups} from "../../../src/database/groups";
import {NetworkError, nextHandler} from "../../../src/utils/errors";
import getConfig from "next/config";
import {addSchemas, waitForInitSchemas} from "../../../src/validation/schemas";
const {publicRuntimeConfig: {TENANT_TYPE}} = getConfig()
const handler = nextHandler(async (req, res) => {
if (req.method !== 'GET') throw new NetworkError('Invalid method')
if (TENANT_TYPE !== 'local') throw new NetworkError('Not allowed', undefined, 400)
await waitForInitSchemas.resolve()
res.json({ success: true })
})
export default handler

View File

@@ -1,12 +1,14 @@
import {NextApiHandler} from "next";
import {GroupData, setGroups} from "../../src/database/groups"; import {GroupData, setGroups} from "../../src/database/groups";
import {nextHandler} from "../../src/utils/errors";
import {waitForInitSchemas} from "../../src/validation/schemas";
const handler: NextApiHandler = async (req, res) => { const handler = nextHandler(async (req, res) => {
if (req.method !== 'POST') throw new Error('Invalid method') if (req.method !== 'POST') throw new Error('Invalid method')
await waitForInitSchemas.resolve()
const data = req.body as GroupData const data = req.body as GroupData
const uuid = await setGroups(data) const uuid = await setGroups(data)
res.json({ uuid }) res.json({ uuid })
} })
export default handler export default handler

View File

@@ -1,6 +1,6 @@
import {database} from "./start"; import {database} from "./start";
import {Dict, Group} from "../types"; import {Dict, Group} from "../types";
import {Filter, ObjectId, WithId} from "mongodb"; import {Filter, ObjectId} from "mongodb";
export interface GroupData { export interface GroupData {
groups: Dict<Group>, groups: Dict<Group>,
@@ -8,7 +8,7 @@ export interface GroupData {
base: string[] base: string[]
} }
type InsertMeta<T> = T & { export type InsertMeta<T> = T & {
createdOn: Date, createdOn: Date,
modifiedOn: Date, modifiedOn: Date,
accessedOn: Date accessedOn: Date
@@ -16,8 +16,8 @@ type InsertMeta<T> = T & {
type GroupFilter = Filter<InsertMeta<GroupData>> type GroupFilter = Filter<InsertMeta<GroupData>>
export async function setGroups(data: GroupData) { export async function setGroups(data: GroupData): Promise<string|undefined> {
const collection = (await database.resolve())?.collection<InsertMeta<GroupData>>('setups') const collection = (await database.resolve())?.collection('setups')
if (!collection) return if (!collection) return
const result = await collection.insertOne({ const result = await collection.insertOne({
...data, ...data,
@@ -29,16 +29,82 @@ export async function setGroups(data: GroupData) {
return result.insertedId.toString() return result.insertedId.toString()
} }
function getUuid(uuid: string): GroupFilter {
return {
_id: new ObjectId(uuid)
}
}
export async function setFactories(uuid: string, type: 'ignored'|'base', factories: string[]): Promise<boolean> {
const collection = (await database.resolve())?.collection('setups')
if (!collection) return false
collection.updateOne(getUuid(uuid), {$set: {[type]: factories} as never})
return true
}
export async function getGroup(uuid: string) { export async function getGroup(uuid: string) {
const collection = (await database.resolve())?.collection<InsertMeta<GroupData>>('setups') const collection = (await database.resolve())?.collection('setups')
if (!collection) return if (!collection) return
console.log(uuid) const data = (await collection.findOne(getUuid(uuid))) ?? undefined
const data = (await collection.findOne({
_id: new ObjectId(uuid)
} as GroupFilter)) ?? undefined
if (data) { if (data) {
await collection.updateOne({_id: new ObjectId(uuid)}, { $set: {accessedOn: new Date()}}) await collection.updateOne(getUuid(uuid), { $set: {accessedOn: new Date()}})
} }
return data return data
} }
export async function renameGroup(uuid: string, oldName: string, newName: string): Promise<boolean> {
oldName = oldName.replace(/[.$]/g, '')
newName = newName.replace(/[.$]/g, '')
const data = await getGroup(uuid)
if (data?.groups && !(newName in data.groups)) {
const collection = (await database.resolve())?.collection('setups')
console.log("fere", `groups.${oldName}`, `groups.${newName}`)
if (!collection) return false
await collection.updateOne(getUuid(uuid), { $set: {
[`groups.${oldName}.name`]: newName
} as never})
await collection.updateOne(getUuid(uuid), { $rename: {
[`groups.${oldName}`]: `groups.${newName}`
}})
return true
}
return false
}
export async function addGroup(uuid: string, name: string): Promise<boolean> {
name = name.replace(/[.$]/g, '')
const data = await getGroup(uuid)
if (data?.groups && !(name in data.groups)) {
const collection = (await database.resolve())?.collection('setups')
if (!collection) return false
await collection.updateOne(getUuid(uuid), {$set: {[`groups.${name}`]: {name, exports: [], malls: []} as never}})
return true
}
return false
}
export async function removeGroup(uuid: string, name: string): Promise<boolean> {
name = name.replace(/[.$]/g, '')
const data = await getGroup(uuid)
if (data?.groups && name in data.groups) {
const collection = (await database.resolve())?.collection('setups')
if (!collection) return false
console.log(`groups.${name}`)
await collection.updateOne(getUuid(uuid), {$unset: {[`groups.${name}`]: ""}})
return true
}
return false
}
export async function setFactoriesOfGroup(uuid: string, name: string, type: 'exports'|'malls', factories: string[]): Promise<boolean> {
name = name.replace(/[.$]/g, '')
const data = await getGroup(uuid)
if (data?.groups && (name in data.groups)) {
const collection = (await database.resolve())?.collection('setups')
if (!collection) return false
await collection.updateOne(getUuid(uuid), {$set: {[`groups.${name}.${type}`]: factories} as never})
return true
}
return false
}

View File

@@ -1,6 +1,7 @@
import {Db, MongoClient} from 'mongodb' import {Collection, Db, MongoClient} from 'mongodb'
import getConfig from 'next/config' import getConfig from 'next/config'
import {Resolvable} from "../utils/Resolvable"; import {Resolvable} from "../utils/Resolvable";
import {GroupData, InsertMeta} from "./groups";
const { serverRuntimeConfig: { const { serverRuntimeConfig: {
MONGO_URL, MONGO_URL,
@@ -9,12 +10,14 @@ const { serverRuntimeConfig: {
MONGO_DB MONGO_DB
} } = getConfig() } } = getConfig()
async function getDatabase(): Promise<Db | undefined> { async function getDatabase() {
const url = `mongodb://${MONGO_USER ? `${MONGO_USER}:${MONGO_PASS ?? ''}@` : ''}${MONGO_URL}`; const url = `mongodb://${MONGO_USER ? `${MONGO_USER}:${MONGO_PASS ?? ''}@` : ''}${MONGO_URL}`;
const client = new MongoClient(url); const client = new MongoClient(url);
await client.connect(); await client.connect();
console.log('Connected successfully to server') console.log('Connected successfully to server')
return client.db(MONGO_DB) return client.db(MONGO_DB) as unknown as (Omit<Db, 'collection'> & {
collection : (_: 'setups') => Collection<InsertMeta<GroupData>>
})
} }
export const database = new Resolvable(getDatabase) export const database = new Resolvable(getDatabase)

2
src/next-types.d.ts vendored
View File

@@ -11,12 +11,12 @@ declare module 'next/config' {
} }
export interface PublicRuntimeConfig { export interface PublicRuntimeConfig {
TENANT_TYPE?: string
} }
const getConfig: () => { const getConfig: () => {
serverRuntimeConfig: ServerRuntimeConfig, serverRuntimeConfig: ServerRuntimeConfig,
publicRuntimeConfig: PublicRuntimeConfig publicRuntimeConfig: PublicRuntimeConfig
} }
//export const serConfig = () =>
export default getConfig export default getConfig
} }

25
src/types/ApiSchemas.ts Normal file
View File

@@ -0,0 +1,25 @@
/* tslint:disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface GroupRenameBody {
newName: string
}
export interface GroupSetFactoryArrayBody {
type: 'exports' | 'malls'
factories: string[]
}
export interface GroupIdParam {
id: string
name: string
}
export interface SetFactoryArrayBody {
type: 'ignored' | 'base'
factories: string[]
}
export interface IdParam {
id: string
}

View File

@@ -0,0 +1,30 @@
/* tslint:disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface GroupRenameBody {
newName: string
[k: string]: unknown
}
export interface GroupSetFactoryArrayBody {
type: 'exports' | 'malls'
factories: string[]
[k: string]: unknown
}
export interface GroupIdParam {
id: string
name: string
[k: string]: unknown
}
export interface SetFactoryArrayBody {
type: 'ignored' | 'base'
factories: string[]
[k: string]: unknown
}
export interface IdParam {
id: string
[k: string]: unknown
}

7
src/types/FrontendApi.ts Normal file
View File

@@ -0,0 +1,7 @@
import {ValidationError} from "jsonschema";
export interface ErrorMessage {
message: string // Human readable error message
details?: string | string[] // Stacktrace, details, extra information...
validation?: ValidationError[]
}

36
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ErrorMessage } from '../types/FrontendApi'
import {NextApiHandler, NextApiRequest, NextApiResponse} from 'next'
import { logger } from './logger'
import {NextFetchEvent, NextMiddleware, NextRequest} from "next/server";
export class NetworkError extends Error {
public content?: ErrorMessage
constructor(
content?: ErrorMessage | string,
public throwingError?: unknown,
public statusCode = 500
) {
super((typeof content === 'string' ? content : content?.message) ?? 'NetworkError')
this.name = 'NetworkError'
this.content = typeof content === 'string' ? { message: content } : content
}
}
export function nextHandler(fn: NextApiHandler): NextApiHandler {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
await fn(req, res)
} catch (err) {
if (err instanceof NetworkError) {
const ntwErr = err as NetworkError
if (ntwErr.throwingError) logger.error(ntwErr.throwingError)
res.status(ntwErr.statusCode)
res.json(ntwErr.content ?? { message: 'An error occurred!' })
} else {
logger.error(err)
res.status(500).json({ message: 'An error occurred!' })
}
}
}
}

7
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,7 @@
export const logger = {
verbose: console.log,
debug: console.log,
info: console.info,
warn: console.warn,
error: console.error
}

142
src/validation/defaults.ts Normal file
View File

@@ -0,0 +1,142 @@
import { JSONSchema } from 'json-schema-to-typescript'
function cloneJSON<T>(source: T) {
return JSON.parse(JSON.stringify(source)) as T
}
export function isObject(item: unknown): item is Record<string, unknown> {
return typeof item === 'object' && item !== null && item.toString() === {}.toString()
}
export function merge<T extends Record<string, unknown>, U extends Record<string, unknown>>(
target: T,
source: U
): T & U {
const result = cloneJSON(target) as T & U
for (const key in source) {
// eslint-disable-next-line no-prototype-builtins
if (source.hasOwnProperty(key)) {
const tk = result[key]
const sk = source[key]
if (isObject(tk) && isObject(sk)) {
result[key] = merge(tk, sk)
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
result[key] = source[key]
}
}
}
return result
}
function getLocalRef(path: string, definitions: JSONSchema['definitions']) {
const pathSplit = path.replace(/^#\/definitions\//, '').split('/')
const find = (p: string[], root: JSONSchema['definitions']): JSONSchema => {
const key = p.shift()
if (!root || !key || !root[key]) {
return {}
} else if (!p.length) {
return root[key]
}
return find(p, root[key] as JSONSchema['definitions'])
}
const result = find(pathSplit, definitions)
if (!isObject(result)) {
return result
}
return cloneJSON(result)
}
function mergeAllOf(
allOfList: Exclude<JSONSchema['allOf'], undefined>,
definitions: JSONSchema['definitions']
) {
const length = allOfList.length
let index = -1
let result = {}
while (++index < length) {
let item = allOfList[index]
item = typeof item.$ref !== 'undefined' ? getLocalRef(item.$ref, definitions) : item
result = merge(result, item as Record<string, unknown>)
}
return result
}
function defaults(schema: JSONSchema, definitions: JSONSchema['definitions']): unknown {
if (typeof schema.default !== 'undefined') {
return schema.default
} else if (typeof schema.allOf !== 'undefined') {
const mergedItem = mergeAllOf(schema.allOf, definitions)
return defaults(mergedItem, definitions)
} else if (typeof schema.$ref !== 'undefined') {
const reference = getLocalRef(schema.$ref, definitions)
return defaults(reference, definitions)
} else if (schema.type === 'object') {
if (!schema.properties) {
return {}
}
const result: Record<string, unknown> = {}
for (const key in schema.properties) {
if (key in schema.properties) {
result[key] = defaults(schema.properties[key], definitions)
if (typeof result[key] === 'undefined') {
delete result[key]
}
}
}
return result
} else if (schema.type === 'array') {
if (!schema.items) {
return []
}
// minimum item count
const ct = schema.minItems || 0
// tuple-typed arrays
if (schema.items instanceof Array) {
const values = schema.items.map(item => defaults(item, definitions))
// remove undefined items at the end (unless required by minItems)
for (let i = values.length - 1; i >= 0; i--) {
if (typeof values[i] !== 'undefined') {
break
}
if (i + 1 > ct) {
values.pop()
}
}
return values
}
// object-typed arrays
const value = defaults(schema.items, definitions)
if (typeof value === 'undefined') {
return []
}
const values: unknown[] = []
for (let i = 0; i < Math.max(1, ct); i++) {
values.push(cloneJSON(value))
}
return values
}
return undefined
}
export function getDefaults(
schema: JSONSchema,
definitions?: JSONSchema['definitions'] | undefined
): unknown {
if (typeof definitions === 'undefined') {
definitions = schema.definitions || {}
} else if (isObject(schema.definitions)) {
definitions = merge(definitions, schema.definitions)
}
return defaults(cloneJSON(schema), definitions)
}

113
src/validation/index.ts Normal file
View File

@@ -0,0 +1,113 @@
import { Validator } from 'jsonschema'
import { getDefaults, isObject, merge } from './defaults'
import { TransformedValidatorResult, ValidatorOptions } from './types'
import { transformInstance } from './transform'
import { compile, JSONSchema } from 'json-schema-to-typescript'
import * as fs from 'fs/promises'
import { join } from 'path'
import { NetworkError } from '../utils/errors'
import getConfig from "next/config";
const {publicRuntimeConfig: {TENANT_TYPE}} = getConfig()
const validatorStorage = new Validator()
export async function addSchema(schemas: JSONSchema[]) {
if (TENANT_TYPE === 'local') {
const fileName = join('./src/types/ApiSchemas.ts')
const fileNameFrontend = join('./src/types/ApiSchemasFrontend.ts')
const style = await fs.readFile('./.prettierrc.json', 'utf8').then(JSON.parse)
let data = ''
let dataFrontend = ''
for (const schema of schemas) {
if (!schema.id) throw new SyntaxError('Id of schema has to be defined!')
validatorStorage.addSchema(schema)
// compile schema to interface
let entity = await compile(schema, schema.id, {
style,
bannerComment: data === '' ? undefined : '\n\n\n'
})
entity = entity.replace(/^( {2})+\[k: string]: unknown\n/gm, '')
dataFrontend += entity
// remove ? from defaulted properties
const defaults = getDefaults(schema)
if (isObject(defaults)) {
iterObject(defaults, (key, depth) => {
entity = entity.replace(
RegExp(` {${2 * depth + 2}}${key}\\?`, 'gm'),
' '.repeat(2 * depth + 2) + key
)
})
}
// add interface to others
data += entity
}
const current = await fs.readFile(fileName, 'utf8').catch(() => undefined)
if (current !== data) {
await fs.writeFile(fileName, data)
await fs.writeFile(fileNameFrontend, dataFrontend)
}
} else {
for (const schema of schemas) {
if (!schema.id) throw new SyntaxError('Id of schema has to be defined!')
validatorStorage.addSchema(schema)
}
}
}
export function validate<T = Record<string, unknown>>(
instance: Record<string, unknown>,
schemaName: string,
options?: ValidatorOptions
): TransformedValidatorResult<T> {
const schema = validatorStorage.schemas[schemaName] as JSONSchema | undefined
if (!schema) throw new SyntaxError(`Schema '${schemaName}' not implemented!`)
const defaulted =
options?.applyDefaults !== false ? mergeDefault(getDefaults(schema), instance) : instance
const transformed =
options?.transform !== false ? transformInstance(defaulted, schema) : defaulted
const result = validatorStorage.validate(
transformed,
schema,
options
) as TransformedValidatorResult<T>
if (result.errors.length) {
throw new NetworkError(
{
message: 'Validation error',
validation: result.errors
},
undefined,
400
)
}
result.transformed = transformed as T
return result
}
export function applyDefaults<T>(schemaName: string, instance: Partial<T>): T {
const schema = validatorStorage.schemas[schemaName] as JSONSchema | undefined
if (!schema) throw new SyntaxError(`Schema '${schemaName}' not implemented!`)
return mergeDefault(getDefaults(schema), instance) as T
}
function mergeDefault(target: unknown, source: unknown) {
return isObject(target) && isObject(source) ? merge(target, source) : source
}
function iterObject(
object: Record<string, unknown>,
cb: (key: string, depth: number, value: unknown) => void
) {
function iter(o: Record<string, unknown>, d: number) {
Object.keys(o).forEach(k => {
const next = o[k]
if (isObject(next)) {
iter(next as Record<string, unknown>, d + 1)
} else {
cb(k, d, next)
}
})
}
iter(object, 0)
}

54
src/validation/schemas.ts Normal file
View File

@@ -0,0 +1,54 @@
import { addSchema } from './index'
import { Resolvable } from '../utils/Resolvable'
export function addSchemas() {
return addSchema([
{
id: '/GroupRenameBody',
type: 'object',
required: ['newName'],
properties: {
newName: { type: 'string', minLength: 1 }
}
},
{
id: '/GroupSetFactoryArrayBody',
type: 'object',
required: ['type', 'factories'],
properties: {
type: {type: 'string', enum: ['exports', 'malls']},
factories: {type: 'array', items: {type: 'string', minLength: 3}}
},
},
{
id: 'GroupIdParam',
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'string', minLength: 24, maxLength: 24 },
name: { type: 'string', minLength: 1 }
}
},
{
id: '/SetFactoryArrayBody',
type: 'object',
required: ['type', 'factories'],
properties: {
type: {type: 'string', enum: ['ignored', 'base']},
factories: {type: 'array', items: {type: 'string', minLength: 3}}
},
},
{
id: 'IdParam',
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', minLength: 24, maxLength: 24 },
}
}
])
}
export const waitForInitSchemas = new Resolvable(() => {
return addSchemas()
})

View File

@@ -0,0 +1,72 @@
import { Schema, validate, ValidationError } from 'jsonschema'
import deepcopy from 'deepcopy'
import { JSONSchema } from 'json-schema-to-typescript'
const types: Record<
string,
{
test: (_: unknown) => boolean
cast: (_: unknown) => unknown
redo?: boolean
}
> = {
integer: {
test: val => !isNaN(val as number),
cast: val => parseInt(`${val}`, 10)
},
number: {
test: val => !isNaN(val as number),
cast: val => parseFloat(`${val}`)
},
boolean: {
test: val => val === true || val === false,
cast: val => val === 'true' || val === '1'
},
array: {
test: Array.isArray,
cast: val => `${val}`.split(',').map(x => x.trim()),
redo: true
}
}
function set(obj: Record<string, unknown>, is: (string | number)[], value: unknown): void {
if (is.length === 1 && value !== undefined) {
obj[is[0]] = value
} else {
set(obj[is[0]] as Record<string, unknown>, is.slice(1), value)
}
}
function get(obj: Record<string, unknown>, is: (string | number)[]): unknown {
if (is.length === 0) {
return obj
}
return get(obj[is[0]] as Record<string, unknown>, is.slice(1))
}
export function transformInstance(instance: unknown, schema: JSONSchema): unknown {
const errors = validate(instance, schema).errors
const BASE_NAME = 'instance'
const carrier = {
[BASE_NAME]: deepcopy(instance)
}
const shouldRedo = errors
.filter(error => error.name === 'type')
.filter(error => typeof error.schema !== 'string' && `${error.schema.type}` in types)
.map((error: ValidationError & { schema: Schema }) => {
const type = types[`${error.schema.type}`]
const path = [BASE_NAME, ...error.path]
const val = type.cast(get(carrier, path))
if (type.test(val)) {
set(carrier, path, val)
}
return type.redo
})
return shouldRedo.some(x => x)
? transformInstance(carrier[BASE_NAME], schema)
: carrier[BASE_NAME]
}

10
src/validation/types.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Options, ValidatorResult } from 'jsonschema'
export interface ValidatorOptions extends Options {
applyDefaults?: boolean
transform?: boolean
}
export interface TransformedValidatorResult<T> extends ValidatorResult {
transformed: T
}

3625
yarn.lock

File diff suppressed because it is too large Load Diff