Skip to content

Commit

Permalink
feature: refactor API to use authentication and assertions
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Carlson committed Dec 11, 2024
1 parent dc441d8 commit 1f3fc31
Show file tree
Hide file tree
Showing 43 changed files with 728 additions and 707 deletions.
6 changes: 4 additions & 2 deletions packages/app/auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { authOptions } from 'pages/api/auth/[...nextauth]'
import { getServerSession } from 'next-auth'
import { UserWithRoles } from './types'

export async function getLoggedInUser(req: any, res: any): Promise<UserWithRoles> {
// TODO: figure out correct typings
export async function getLoggedInUser(
req: any,
res: any
): Promise<UserWithRoles | undefined> {
const session = await getServerSession(req, res, authOptions)

if (!session?.user?.uid) {
Expand Down
8 changes: 4 additions & 4 deletions packages/app/declarations.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DetailedHTMLProps, HTMLAttributes } from 'react'
import { HttpError } from 'http-errors'
import { JSX as LocalJSX } from '@gesdisc/meditor-components/loader'
import 'next-auth'
import type { CKEditor } from 'ckeditor4-react'
import { DetailedHTMLProps, HTMLAttributes } from 'react'
import type { HttpException } from './utils/errors'
import type { Stan } from 'node-nats-streaming'
import 'next-auth'
import type { User as NextAuthUser } from 'next-auth'

// Read more at: https://next-auth.js.org/getting-started/typescript#module-augmentation
Expand All @@ -30,7 +30,7 @@ export type APIError = {
error: string
}

export type ErrorData<T> = [Error | HttpException | null, T | null]
export type ErrorData<T> = [Error | HttpError | null, T | null]

type gReactProps<T> = {
[P in keyof T]?: DetailedHTMLProps<HTMLAttributes<T[P]>, T[P]>
Expand Down
65 changes: 65 additions & 0 deletions packages/app/lib/with-api-error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { ZodError } from 'zod'
import createError from 'http-errors'
import { parameterWithInflection } from './grammar'
import log from './log'

export function withApiErrorHandler(handler) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
// Call the original handler
await handler(req, res)
} catch (err) {
// Any unhandled exceptions will be caught and reported as a clean JSON response
return apiError(err, res)
}
}
}

/**
* converts errors to a JSON api response
* To prevent leaking implementation details to an end-user, if the error isn't an instance of HttpError, only return a generic error.
*/
export function apiError(
error: Error | ZodError | createError.HttpError,
response: NextApiResponse
) {
let interstitialError = error

if (error.name === 'ZodError') {
interstitialError = formatZodError(error as ZodError)
}

const safeError =
interstitialError instanceof createError.HttpError
? interstitialError
: new createError.InternalServerError()

if (safeError instanceof createError.InternalServerError) {
// this is an unknown error, lets dump out the details so we can debug it later
log.error(safeError)
}

return response.status(safeError.status).json({
status: safeError.status,
error: safeError.message,
})
}

export function formatZodError(error: ZodError, messagePrefix?: string) {
const errorString = error.issues.reduce((accumulator, current, index, self) => {
//* We want spaces between errors but not for the last error.
const maybeSpace = index + 1 === self.length ? '' : ' '

accumulator += `${
messagePrefix ??
`For query ${parameterWithInflection(
current.path.length
)} ${current.path.toString()}: `
}${current.message}.${maybeSpace}`

return accumulator
}, ``)

return new createError.BadRequest(errorString)
}
16 changes: 16 additions & 0 deletions packages/app/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export { default } from 'next-auth/middleware'

export const config = {
matcher: [
/*
* Require authentication for all request paths except for the ones starting with:
* - installation
* - signin (mEditor's sign in page)
* - api (API routes)
* - _next (NextJS static files)
* - images (/public/images static images)
* - favicon.ico
*/
'/((?!installation|signin|api|_next|images|favicon.ico).*)',
],
}
15 changes: 8 additions & 7 deletions packages/app/models/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import jsonpath from 'jsonpath'
import type { ErrorData } from '../declarations'
import { getDocumentsDb } from '../documents/db'
import log from '../lib/log'
import { runModelTemplates } from '../macros/service'
import { ErrorCode, HttpException } from '../utils/errors'
import { isJson } from '../utils/jsonschema-validate'
import { getWorkflowByDocumentState } from '../workflows/service'
import { getDocumentsDb } from '../documents/db'
import { getModelsDb } from './db'
import { getWorkflowByDocumentState } from '../workflows/service'
import { isJson } from '../utils/jsonschema-validate'
import { runModelTemplates } from '../macros/service'
import type { ErrorData } from '../declarations'
import type { Model, ModelWithWorkflow } from './types'
import type { UserWithRoles } from 'auth/types'

Expand Down Expand Up @@ -203,7 +203,8 @@ export async function getModelsWithDocumentCount(): Promise<ErrorData<Model[]>>

/**
* if user is not authenticated, verify the requested model is not in the list of models requiring authentication
* this was a mEditor design decision early on to allow anonymous access to most documents
*/
export function userCanAccessModel(modelName: string, user: UserWithRoles) {
return !!user?.uid || !MODELS_REQUIRING_AUTHENTICATION.includes(modelName)
export async function userCanAccessModel(user: UserWithRoles, modelName: string) {
return user?.uid ? true : !MODELS_REQUIRING_AUTHENTICATION.includes(modelName)
}
45 changes: 45 additions & 0 deletions packages/app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"eslint-config-next": "^15.0.3",
"fuse.js": "^6.6.2",
"he": "^1.2.0",
"http-errors": "^2.0.0",
"immer": "^9.0.6",
"immutable-json-patch": "^6.0.1",
"isomorphic-unfetch": "^3.0.0",
Expand Down
11 changes: 6 additions & 5 deletions packages/app/pages/api/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { withApiErrorHandler } from 'lib/with-api-error-handler'
import type { NextApiRequest, NextApiResponse } from 'next'
import createError from 'http-errors'

export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
// TODO: implement
res.status(501).json({
message: 'Not Implemented',
})
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
throw createError.NotImplemented()
}

export default withApiErrorHandler(handler)
25 changes: 12 additions & 13 deletions packages/app/pages/api/admin/seed-db/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { setUpNewInstallation } from '../../../../setup/service'
import { apiError } from '../../../../utils/errors'
import { withApiErrorHandler } from 'lib/with-api-error-handler'
import assert from 'assert'
import createError from 'http-errors'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
switch (req.method) {
case 'POST': {
const [error] = await setUpNewInstallation(req.body)
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
assert(req.method === 'POST', new createError.MethodNotAllowed())

if (!!error) {
return apiError(error, res)
}
const [error] = await setUpNewInstallation(req.body)

return res.status(204).end()
}

default:
return res.status(405).end()
if (!!error) {
throw error
}

return res.status(204).end()
}

export default withApiErrorHandler(handler)
5 changes: 4 additions & 1 deletion packages/app/pages/api/health/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withApiErrorHandler } from 'lib/with-api-error-handler'
import type { NextApiRequest, NextApiResponse } from 'next'

const healthcheck = {
Expand All @@ -8,7 +9,7 @@ const healthcheck = {
},
}

export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//timeout for fetch request sice meditor_notifier is an internal service
const controller = new AbortController()

Expand All @@ -33,3 +34,5 @@ export default async function handler(_req: NextApiRequest, res: NextApiResponse
res.status(500).json({ message: 'Meditor API is not healthy', err })
}
}

export default withApiErrorHandler(handler)
18 changes: 8 additions & 10 deletions packages/app/pages/api/legacy-endpoints/changeDocumentState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { apiError } from 'utils/errors'
import changeDocumentStateHandler from '../models/[modelName]/documents/[documentTitle]/change-document-state'
import { baseDocumentSchema } from './_schemas'
import { z } from 'zod'
import { withApiErrorHandler } from 'lib/with-api-error-handler'

const schema = baseDocumentSchema
.extend({
Expand All @@ -14,14 +14,12 @@ const schema = baseDocumentSchema
state,
}))

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = schema.parse(req.query)
req.method = req.method === 'GET' ? 'POST' : req.method // old API used GET, new API uses POST
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = schema.parse(req.query)
req.method = req.method === 'GET' ? 'POST' : req.method // old API used GET, new API uses POST

return changeDocumentStateHandler(req, res)
} catch (err) {
return apiError(err, res)
}
return changeDocumentStateHandler(req, res)
}

export default withApiErrorHandler(handler)
16 changes: 7 additions & 9 deletions packages/app/pages/api/legacy-endpoints/cloneDocument.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { apiError } from 'utils/errors'
import cloneDocumentHandler from '../models/[modelName]/documents/[documentTitle]/clone-document'
import { baseDocumentSchema } from './_schemas'
import { z } from 'zod'
import { withApiErrorHandler } from 'lib/with-api-error-handler'

const schema = baseDocumentSchema
.extend({
Expand All @@ -14,13 +14,11 @@ const schema = baseDocumentSchema
newTitle,
}))

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = schema.parse(req.query)
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = schema.parse(req.query)

return cloneDocumentHandler(req, res)
} catch (err) {
return apiError(err, res)
}
return cloneDocumentHandler(req, res)
}

export default withApiErrorHandler(handler)
16 changes: 7 additions & 9 deletions packages/app/pages/api/legacy-endpoints/getComments.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { apiError } from 'utils/errors'
import getCommentsHandler from '../models/[modelName]/documents/[documentTitle]/comments/'
import { documentSchema } from './_schemas'
import { withApiErrorHandler } from 'lib/with-api-error-handler'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = documentSchema.parse(req.query)
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// replaces query params with params mapped to RESTful names (e.g. "model" -> "modelName", etc.)
req.query = documentSchema.parse(req.query)

return getCommentsHandler(req, res)
} catch (err) {
return apiError(err, res)
}
return getCommentsHandler(req, res)
}

export default withApiErrorHandler(handler)
Loading

0 comments on commit 1f3fc31

Please sign in to comment.