Skip to content

Commit

Permalink
feat: extend contracts and roles for collection functions
Browse files Browse the repository at this point in the history
  • Loading branch information
minenwerfer committed Mar 10, 2024
1 parent a4ccb1d commit 0987925
Show file tree
Hide file tree
Showing 20 changed files with 247 additions and 112 deletions.
4 changes: 2 additions & 2 deletions packages/aeria-sdk/src/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const mirrorDts = (mirrorObj: any) => {
SchemaWithId,
MakeEndpoint,
RequestMethod,
CollectionFunctions
CollectionFunctionsPaginated
} from '@aeriajs/types'
Expand Down Expand Up @@ -58,7 +58,7 @@ declare module 'aeria-sdk' {
type StrongelyTypedTLO = TopLevelObject & Endpoints & {
[K in keyof MirrorDescriptions]: SchemaWithId<MirrorDescriptions[K]> extends infer Document
? CollectionFunctions<Document> extends infer Functions
? CollectionFunctionsPaginated<Document> extends infer Functions
? Omit<TLOFunctions, keyof Functions> & {
[P in keyof Functions]: {
POST: Functions[P]
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const getFunction = async <
acProfile?: ACProfile,
) => {
if( acProfile ) {
if( !await isGranted(String(collectionName), String(functionName), acProfile) ) {
if( !await isGranted(collectionName, functionName, acProfile) ) {
return left(ACErrors.AuthorizationError)
}
}
Expand Down
13 changes: 12 additions & 1 deletion packages/api/src/collection/define.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { SchemaWithId, Collection, Context, Description } from '@aeriajs/types'
import type {
SchemaWithId,
Collection,
Context,
Contract,
Description,

} from '@aeriajs/types'

export const defineCollection = <
TCollection extends Collection<TCollection extends Collection ? TCollection : never> extends infer Coll
Expand All @@ -9,13 +16,17 @@ export const defineCollection = <
>
: never,
const TDescription extends Description<TDescription>,
const TFunctionContracts extends Partial<{
[P in keyof TFunctions]: Contract
}>,
const TFunctions extends {
[P: string]: (payload: any, context: Context<TDescription>, ...args: any[])=> any
},
>(
collection: TCollection & {
description: TDescription
functions?: TFunctions
functionContracts?: TFunctionContracts
},
) => {
return collection as TCollection & {
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/collection/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './cascadingRemove.js'
export * from './define.js'
export * from './description.js'
export * from './pagination.js'
export * from './preload.js'
export * from './reference.js'
export * from './traverseDocument.js'
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/collection/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Context, Pagination, GetAllPayload } from '@aeriajs/types'

export const makePagination = async (
payload: GetAllPayload<any>,
documents: any[],
context: Context
): Promise<Pagination> => {
const limit = payload.limit
? payload.limit
: context.config.paginationLimit!

const offset = payload.offset || 0

const recordsTotal = 'count' in context.collection.functions
? await context.collection.functions.count(payload)
: await context.collection.model.countDocuments({
filters: payload.filters
})

return {
recordsCount: documents.length,
recordsTotal,
offset,
limit,
}
}
2 changes: 2 additions & 0 deletions packages/api/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const indepthCollection = (collectionName: string, collections: Record<string, C
const childContext = await createContext({
parentContext,
collectionName,
inherited: true,
})

return collection.functions[functionName](props, childContext, ...args)
Expand Down Expand Up @@ -91,3 +92,4 @@ export const createContext = async (_options?: ContextOptions<any>) => {
Object.assign(context, await internalCreateContext(options, context))
return context
}

7 changes: 4 additions & 3 deletions packages/api/src/functions/getAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const getAll = async <
>(
_payload: GetAllPayload<SchemaWithId<Context['description']>> | null,
context: TContext,
options?: GetAllOptions,
options: GetAllOptions = {},
) => {
const security = useSecurity(context)
const payload = _payload || {}
Expand Down Expand Up @@ -103,7 +103,7 @@ export const getAll = async <
}

const result = await context.collection.model.aggregate(pipeline).toArray()
const documents: typeof result = []
const documents: TDocument[] = []

for( const document of result ) {
documents.push(unsafe(await traverseDocument(fill(document, context.description), context.description, {
Expand All @@ -114,5 +114,6 @@ export const getAll = async <
})))
}

return documents as TDocument[]
return documents
}

51 changes: 51 additions & 0 deletions packages/api/src/getEndpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { RoutesMeta, RouteUri } from '@aeriajs/http'
import { getCollections, getRouter } from '@aeriajs/entrypoint'
import { grantedFor } from '@aeriajs/access-control'
import { deepMerge } from '@aeriajs/common'

export const getEndpoints = async (): Promise<RoutesMeta> => {
const router = await getRouter()
const collections = await getCollections()

const functions: RoutesMeta = {}

for( const collectionName in collections ) {
const candidate = collections[collectionName]
const collection = typeof candidate === 'function'
? candidate()
: candidate

const {
description,
functions: collectionFunctions,
functionContracts
} = collection

if( collectionFunctions ) {
for( const fnName in collectionFunctions ) {
const endpoint = `/${description.$id}/${fnName}`
const roles = await grantedFor(description.$id, fnName)

const contract = functionContracts && fnName in functionContracts
? roles
? Object.assign({ roles }, functionContracts[fnName])
: functionContracts[fnName]
: roles
? { roles }
: null

functions[endpoint as RouteUri] = {
POST: contract
}
}
}
}

const result = deepMerge(
functions,
router.routesMeta
)

return result
}

3 changes: 2 additions & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
export * from './assets.js'
export * from './collection/index.js'
export * from './context.js'
export * from './token.js'
export * from './database.js'
export * from './functions/index.js'
export * from './getEndpoints.js'
export * from './token.js'
export * from './use.js'
export * as functions from './functions/index.js'
export {
Expand Down
8 changes: 4 additions & 4 deletions packages/builtins/src/functions/describe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Context, Either } from '@aeriajs/types'
import type { Description } from '@aeriajs/types'
import { createContext, preloadDescription } from '@aeriajs/api'
import { getCollections, getRouter } from '@aeriajs/entrypoint'
import { createContext, preloadDescription, getEndpoints } from '@aeriajs/api'
import { getCollections } from '@aeriajs/entrypoint'
import { serialize, isLeft, left, unwrapEither } from '@aeriajs/common'
import { getAvailableRoles } from '@aeriajs/access-control'
import { authenticate } from '../collections/user/authenticate.js'
Expand Down Expand Up @@ -61,6 +61,7 @@ export const describe = async (contextOrPayload: Context | Payload) => {
: candidate

const { description: rawDescription } = collection

const description = await preloadDescription(rawDescription)
descriptions[description.$id] = description
}
Expand All @@ -70,8 +71,7 @@ export const describe = async (contextOrPayload: Context | Payload) => {
}

if( props.router ) {
const router = await getRouter()
result.router = router.routesMeta
result.router = await getEndpoints()
}

if( props.noSerialize || !('response' in contextOrPayload) ) {
Expand Down
5 changes: 5 additions & 0 deletions packages/http/@types/resources.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Collection } from '../src/types'

declare global {
type Collections = Record<string, Collection>
}
15 changes: 3 additions & 12 deletions packages/http/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import type { Property } from '@aeriajs/types'
import type { ContractWithRoles } from '@aeriajs/types'

export type Contract =
| { response: Property | Property[] }
| { payload: Property }
| { query: Property }
| {
response?: Property | Property[]
payload?: Property
query?: Property
}

export const defineContract = <const TContract extends Contract>(contract: TContract) => {
export const defineContract = <const TContractWithRoles extends ContractWithRoles>(contract: TContractWithRoles) => {
return contract
}

67 changes: 45 additions & 22 deletions packages/http/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import type {
InferProperty,
InferResponse,
PackReferences,
ContractWithRoles,
} from '@aeriajs/types'
import type { Contract } from './contract.js'

import { REQUEST_METHODS } from '@aeriajs/types'
import { Stream } from 'stream'
import { pipe, left, isLeft, unwrapEither, deepMerge } from '@aeriajs/common'
import { pipe, arraysIntersects, left, isLeft, unwrapEither, deepMerge } from '@aeriajs/common'
import { validate } from '@aeriajs/validation'
import { safeJson } from './payload.js'
import { DEFAULT_BASE_URI } from './constants.js'
Expand All @@ -23,18 +23,23 @@ export type RouterOptions = {
base?: RouteUri
}

export type RoutesMeta = Record<
RouteUri,
Partial<Record<RequestMethod, ContractWithRoles | null> | undefined>
>

export type Middleware = (context: Context)=> any

export type RouteGroupOptions = {
base?: RouteUri
}

type TypedContext<TContract extends Contract> = Omit<Context, 'request'> & {
type TypedContext<TContractWithRoles extends ContractWithRoles> = Omit<Context, 'request'> & {
request: Omit<Context['request'], 'payload' | 'query'> & {
payload: TContract extends { payload: infer Payload }
payload: TContractWithRoles extends { payload: infer Payload }
? PackReferences<InferProperty<Payload>>
: never
query: TContract extends { query: infer Query }
query: TContractWithRoles extends { query: infer Query }
? InferProperty<Query>
: any
}
Expand All @@ -43,17 +48,32 @@ type TypedContext<TContract extends Contract> = Omit<Context, 'request'> & {
export type ProxiedRouter<TRouter> = TRouter & Record<
RequestMethod,
<
TCallback extends (context: TypedContext<TContract>)=> TContract extends { response: infer Response }
TCallback extends (context: TypedContext<TContractWithRoles>)=> TContractWithRoles extends { response: infer Response }
? InferResponse<Response>
: any,
const TContract extends Contract,
const TContractWithRoles extends ContractWithRoles,
>(
exp: RouteUri,
cb: TCallback,
contract?: TContract
contract?: TContractWithRoles
)=> ReturnType<typeof registerRoute>
>

const checkUnprocessable = (validationEither: ReturnType<typeof validate>, context: Context) => {
if( isLeft(validationEither) ) {
context.response.writeHead(422, {
'content-type': 'application/json',
})
return validationEither
}
}

const unsufficientRoles = (context: Context) => {
context.response.writeHead(403, {
'content-type': 'application/json'
})
}

export const matches = <TRequest extends GenericRequest>(
req: TRequest,
method: RequestMethod | RequestMethod[] | null,
Expand Down Expand Up @@ -88,7 +108,7 @@ export const registerRoute = async <TCallback extends (context: Context)=> any>(
method: RequestMethod | RequestMethod[],
exp: RouteUri,
cb: TCallback,
contract?: Contract,
contract?: ContractWithRoles,
options: RouterOptions = {},
) => {
const match = matches(context.request, method, exp, options)
Expand Down Expand Up @@ -116,18 +136,21 @@ export const registerRoute = async <TCallback extends (context: Context)=> any>(
Object.assign(context.request, match)

if( contract ) {
const checkUnprocessable = (validationEither: ReturnType<typeof validate>) => {
if( isLeft(validationEither) ) {
context.response.writeHead(422, {
'content-type': 'application/json',
})
return validationEither
if( contract.roles ) {
if( !context.token.authenticated ) {
if( !contract.roles.includes('guest') ) {
return unsufficientRoles(context)
}
} else if( context.token.roles ) {
if( !arraysIntersects(context.token.roles, contract.roles) ) {
return unsufficientRoles(context)
}
}
}

if( 'payload' in contract && contract.payload ) {
const validationEither = validate(context.request.payload, contract.payload)
const error = checkUnprocessable(validationEither)
const error = checkUnprocessable(validationEither, context)
if( error ) {
return error
}
Expand All @@ -138,7 +161,7 @@ export const registerRoute = async <TCallback extends (context: Context)=> any>(
coerce: true,
})

const error = checkUnprocessable(validationEither)
const error = checkUnprocessable(validationEither, context)
if( error ) {
return error
}
Expand Down Expand Up @@ -206,18 +229,18 @@ export const createRouter = (options: Partial<RouterOptions> = {}) => {
options.base ??= DEFAULT_BASE_URI

const routes: ((_: unknown, context: Context, groupOptions?: RouteGroupOptions)=> ReturnType<typeof registerRoute>)[] = []
const routesMeta = {} as Record<RouteUri, Partial<Record<RequestMethod, Contract | null> | undefined>>
const routesMeta = {} as RoutesMeta

const route = <
TCallback extends (context: TypedContext<TContract>)=> TContract extends { response: infer Response }
TCallback extends (context: TypedContext<TContractWithRoles>)=> TContractWithRoles extends { response: infer Response }
? InferResponse<Response>
: TContract,
const TContract extends Contract,
: TContractWithRoles,
const TContractWithRoles extends ContractWithRoles,
>(
method: RequestMethod | RequestMethod[],
exp: RouteUri,
cb: TCallback,
contract?: TContract,
contract?: TContractWithRoles,
) => {
routesMeta[exp] ??= {}
routesMeta[exp]![Array.isArray(method)
Expand Down
Loading

0 comments on commit 0987925

Please sign in to comment.