Skip to content

Commit

Permalink
Merge pull request #85 from bouwe77/extended-resources-config
Browse files Browse the repository at this point in the history
 Configure single and plural resources for better OpenAPI specs
  • Loading branch information
bouwe77 authored Sep 6, 2024
2 parents 355053e + f1c2870 commit 3a0804b
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 105 deletions.
22 changes: 20 additions & 2 deletions package-lock.json

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "temba",
"version": "0.36.0",
"version": "0.37.0",
"description": "Get a simple REST API with zero coding in less than 30 seconds (seriously).",
"type": "module",
"main": "dist/src/index.js",
Expand All @@ -22,6 +22,7 @@
"@types/cors": "^2.8.17",
"@types/etag": "^1.8.3",
"@types/express": "^4.17.21",
"@types/indefinite": "^2.3.4",
"@types/morgan": "^1.9.9",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.4.0",
Expand All @@ -43,6 +44,7 @@
"dotenv": "^16.4.5",
"etag": "^1.8.1",
"express": "^4.19.2",
"indefinite": "^2.5.1",
"lowdb": "^7.0.1",
"morgan": "^1.10.0",
"openapi3-ts": "^4.4.0"
Expand Down
14 changes: 12 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ import type { ConfiguredSchemas } from '../schema/types'
import type { RequestInterceptor } from '../requestInterceptor/types'
import type { ResponseBodyInterceptor } from '../responseBodyInterceptor/types'

type ResourcePath = string

type ExtendedResource = {
resourcePath: ResourcePath
singularName: string
pluralName: string
}

type Resources = (ResourcePath | ExtendedResource)[]

export type Config = {
validateResources: boolean
resources: string[]
resources: Resources
apiPrefix: string | null
requestInterceptor: RequestInterceptor | null
responseBodyInterceptor: ResponseBodyInterceptor | null
Expand Down Expand Up @@ -37,7 +47,7 @@ export type RouterConfig = Pick<
>

export type UserConfig = {
resources?: string[]
resources?: Resources
staticFolder?: string
apiPrefix?: string
connectionString?: string
Expand Down
57 changes: 37 additions & 20 deletions src/openapi/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express from 'express'
import type { Config } from '../config'
import { OpenApiBuilder, type ParameterObject } from 'openapi3-ts/oas31'
import indefinite from 'indefinite'

const getPathParameters = (resourceInfo: ResourceInfo, id = false) => {
const { resource, singularResourceLowerCase } = resourceInfo
Expand Down Expand Up @@ -72,23 +73,39 @@ export const createOpenApiRouter = (format: OpenApiFormat, config: Config) => {

if (config.resources.length > 0) {
resourceInfos = config.resources.map((resource) => {
const pluralResourceLowerCase = resource.toLowerCase()
const pluralResourceUpperCase =
pluralResourceLowerCase.charAt(0).toUpperCase() + pluralResourceLowerCase.slice(1)
let singularResourceLowerCase = pluralResourceLowerCase
if (singularResourceLowerCase.endsWith('s')) {
singularResourceLowerCase = singularResourceLowerCase.slice(0, -1)
}
const singularResourceUpperCase =
singularResourceLowerCase.charAt(0).toUpperCase() + singularResourceLowerCase.slice(1)
if (typeof resource === 'string') {
const pluralResourceLowerCase = resource.toLowerCase()
const pluralResourceUpperCase =
pluralResourceLowerCase.charAt(0).toUpperCase() + pluralResourceLowerCase.slice(1)
let singularResourceLowerCase = pluralResourceLowerCase
if (singularResourceLowerCase.endsWith('s')) {
singularResourceLowerCase = singularResourceLowerCase.slice(0, -1)
}
const singularResourceUpperCase =
singularResourceLowerCase.charAt(0).toUpperCase() + singularResourceLowerCase.slice(1)

return {
resource,
pluralResourceLowerCase,
pluralResourceUpperCase,
singularResourceLowerCase,
singularResourceUpperCase,
} satisfies ResourceInfo
return {
resource,
pluralResourceLowerCase,
pluralResourceUpperCase,
singularResourceLowerCase,
singularResourceUpperCase,
} satisfies ResourceInfo
} else {
const pluralResourceLowerCase = resource.pluralName.toLowerCase()
const pluralResourceUpperCase =
pluralResourceLowerCase.charAt(0).toUpperCase() + pluralResourceLowerCase.slice(1)
const singularResourceLowerCase = resource.singularName.toLowerCase()
const singularResourceUpperCase =
singularResourceLowerCase.charAt(0).toUpperCase() + singularResourceLowerCase.slice(1)
return {
resource: resource.resourcePath,
pluralResourceLowerCase,
pluralResourceUpperCase,
singularResourceLowerCase,
singularResourceUpperCase,
} satisfies ResourceInfo
}
})
}

Expand Down Expand Up @@ -271,7 +288,7 @@ export const createOpenApiRouter = (format: OpenApiFormat, config: Config) => {
// GET, HEAD, PUT, PATCH, DELETE on an ID
builder.addPath(`/${resource}/{${singularResourceLowerCase}Id}`, {
get: {
summary: `Find a ${singularResourceLowerCase} by ID`,
summary: `Find ${indefinite(singularResourceLowerCase)} by ID`,
operationId: `get${singularResourceUpperCase}ById`,
parameters: getPathParameters(resourceInfo, true),
responses: {
Expand Down Expand Up @@ -316,7 +333,7 @@ export const createOpenApiRouter = (format: OpenApiFormat, config: Config) => {
},
},
put: {
summary: `Replace a ${singularResourceLowerCase}.`,
summary: `Replace ${indefinite(singularResourceLowerCase)}.`,
operationId: `replace${singularResourceUpperCase}`,
parameters: getPathParameters(resourceInfo, true),
requestBody: {
Expand Down Expand Up @@ -384,7 +401,7 @@ export const createOpenApiRouter = (format: OpenApiFormat, config: Config) => {
},
},
patch: {
summary: `Update a ${singularResourceLowerCase}.`,
summary: `Update ${indefinite(singularResourceLowerCase)}.`,
operationId: `update${singularResourceUpperCase}`,
parameters: getPathParameters(resourceInfo, true),
requestBody: {
Expand Down Expand Up @@ -452,7 +469,7 @@ export const createOpenApiRouter = (format: OpenApiFormat, config: Config) => {
},
},
delete: {
summary: `Delete a ${singularResourceLowerCase}.`,
summary: `Delete ${indefinite(singularResourceLowerCase)}.`,
operationId: `delete${singularResourceUpperCase}`,
parameters: getPathParameters(resourceInfo, true),
responses: {
Expand Down
6 changes: 5 additions & 1 deletion src/resourceRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,13 @@ export const createResourceRouter = (
}

const validateResource = (requestInfo: RequestInfo) => {
const resourcePaths = routerConfig.resources.map((resource) => {
return typeof resource === 'string' ? resource : resource.resourcePath
})

if (
routerConfig.validateResources &&
!routerConfig.resources.includes((requestInfo.resource ?? '').toLowerCase())
!resourcePaths.includes((requestInfo.resource ?? '').toLowerCase())
) {
return createError(404, 'Invalid resource')
}
Expand Down
28 changes: 21 additions & 7 deletions test/integration/custom-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,32 @@ describe('Configuring customRouter + resources', () => {
customRouter.get('/hello', async (_, res) => {
return res.send('Hello, World!')
})
customRouter.get('/goodbye', async (_, res) => {
return res.send('Goodbye, World!')
})

const tembaServer = createServer({
customRouter,
resources: ['hello'],
resources: [
'hello',
{
resourcePath: 'goodbye',
singularName: 'n/a',
pluralName: 'n/a',
},
],
} satisfies UserConfig)

test('customRouter takes presedence over resources', async () => {
// The /hello route is configured both through a custom Express router,
// and as resource, but the customRouter overrides the Temba route.
const response = await request(tembaServer).get('/hello')
expect(response.statusCode).toEqual(200)
expect(response.text).toEqual('Hello, World!')
// The /hello and /goodbye routes are configured both through a custom Express router,
// and as resources, but the customRouter overrides the Temba route.
const helloResponse = await request(tembaServer).get('/hello')
expect(helloResponse.statusCode).toEqual(200)
expect(helloResponse.text).toEqual('Hello, World!')

const goodbyeResponse = await request(tembaServer).get('/goodbye')
expect(goodbyeResponse.statusCode).toEqual(200)
expect(goodbyeResponse.text).toEqual('Goodbye, World!')
})

describe('Configuring customRouter + apiPrefix', () => {
Expand All @@ -73,7 +87,7 @@ describe('Configuring customRouter + resources', () => {
expect(getAllResponse.status).toBe(200)
expect(getAllResponse.body.length).toBe(0)

// The /hello route is is from customRouter, and outside the apiPrefix path, so still works with customRouter.
// The /hello route is from customRouter, and outside the apiPrefix path, so still works with customRouter.
const response2 = await request(tembaServer).get('/hello')
expect(response2.statusCode).toEqual(200)
expect(response2.text).toEqual('Hello, World!')
Expand Down
Loading

0 comments on commit 3a0804b

Please sign in to comment.