Skip to content
This repository has been archived by the owner on Jun 14, 2021. It is now read-only.

Commit

Permalink
feat: response validation (#135)
Browse files Browse the repository at this point in the history
* feat: configurable api documentation path

* chore: add top level jest config

* chore: ignore coverage

* fix: disable validation logging

* feat: allow configuring base path (closes #107)

* feat: validate responses (closes #59)

* docs: update documentation with limitations

* docs: add documentation on document hosting
  • Loading branch information
aimed authored Jul 14, 2019
1 parent e52b902 commit 26a0034
Show file tree
Hide file tree
Showing 21 changed files with 249 additions and 45 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ generated/
yarn-error.log
.DS_STORE
dist/
*.tsbuildinfo
*.tsbuildinfo
coverage/
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"cSpell.words": ["APIV", "Taeschner", "codegen", "commitlint", "lerna", "openapi"]
"cSpell.words": ["APIV", "Taeschner", "codegen", "commitlint", "lerna", "openapi"],
"daddy-jest.jestPath": "${workspaceFolder}/node_modules/.bin/jest"
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { OpenAPIV3 } from 'openapi-types'
import Swagger from 'swagger-parser'
import { TSFile } from '../../typescript/TSFile'

export class OpenApiConstantFactory {
public async create(document: OpenAPIV3.Document, tsFile: TSFile): Promise<string> {
const documentString = JSON.stringify(document)
const documentString = JSON.stringify(await Swagger.dereference(document))
const name = 'openApi'
tsFile.addSourceText(`
export const ${name} = \`
Expand Down
2 changes: 0 additions & 2 deletions docs/_data/nav.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
- to: /
name: Home
- to: /faq
name: FAQ
9 changes: 0 additions & 9 deletions docs/faq/index.md

This file was deleted.

41 changes: 39 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,33 @@ Currently the following code generators are supported:
| ComponentSchemaTypesGenerator | Generates all types for the #/components/schemas section of the OpenApi file. |
| ComponentResponsesGenerator | Generates all types for the #/components/responses section of the OpenApi file. |

## Experimental Features
## Server

### Configuration

#### Custom base path

You can configure a custom base path:

```ts
Slushy.create({
basePath: '/api/v1',
})
```

#### Host interactive Api documentation

Hosting of the Api documentation can be enabled by passing the path to Slushy:

```ts
Slushy.create({
docs: {
path: '/api-docs',
},
})
```

## Features

### File uploads

Expand All @@ -119,9 +145,20 @@ You can (and should) enforce limits on the files that are uploaded. The limits c
**Limitations:**

- File uploads are only possible via multipart/form-data.
- The requestBody MUST be an object and all files MUST be on the root of the object (e.g. `{ file: Buffer, otherBodyProperty: string }`).
- The request body must be an object and all files must be on the root of the object (e.g. `{ file: Buffer, otherBodyProperty: string }`).
- All files are currently read into a Buffer.

## Limitations

When using Slushy, there are a few limitations to your OpenApi schema:

- Every operation can only have a single response content type.
- Every operation with a request body can only accept a single request content type.
- Only multipart/form-data and application/json are accepted request content types.
- Only application/json response content types will be validated.
- All references are resolved at code generation time, which means validation will not adapt to remote schema changes.
- Status code ranges are not fully supported yet (request and response validation).

## Experimental APIs

### Request Context
Expand Down
2 changes: 2 additions & 0 deletions example/__tests__/PetsResource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ describe('PetsResource', () => {
expect(response.status).toBe(200)
expect(response.body).toBeInstanceOf(Array)
})
})

describe('getPetById', () => {
it('should return 400 for an invalid id with a message', async () => {
const response = await request(slushy.app).get('/pets/123')
expect(response.status).toBe(400)
Expand Down
7 changes: 7 additions & 0 deletions example/__tests__/Validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,11 @@ describe('Validation', () => {
expect(response.status).toBe(400)
})
})

describe('response', () => {
it('should not send a response if it fails validation', async () => {
const response = await request(slushy.app).get('/validation/response')
expect(response.status).toBe(500)
})
})
})
17 changes: 16 additions & 1 deletion example/pet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,21 @@ paths:
responses:
'200':
description: Test http operation

/validation/response:
get:
operationId: validationResponse
description: Test response validation
summary: Test response validation
responses:
'200':
description: Test response validation
content:
application/json:
schema:
type: object
properties:
data:
type: string
/validation/query:
get:
operationId: validationQuery
Expand Down Expand Up @@ -91,6 +105,7 @@ paths:
type: string
responses:
'200':
description: Test body default value setter
content:
application/json:
schema:
Expand Down
30 changes: 15 additions & 15 deletions example/src/PetsResourceImpl.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { SlushyContext } from '@slushy/server'
import { Context } from './Context'
import {
PetsResource,
GetPetByIdParams,
CreatePetOK,
CreatePetParams,
CreatePetResponse,
GetPetsResponse,
DefaultResponsesDefault,
DefaultResponsesParams,
GetPetByIdBadRequest,
GetPetByIdOK,
GetPetByIdParams,
GetPetByIdResponse,
GetPetsOK,
GetPetsResponse,
PetsResource,
UploadPetPictureOK,
UploadPetPictureParams,
UploadPetPictureResponse,
GetPetByIdOK,
CreatePetOK,
UploadPetPictureOK,
GetPetsOK,
GetPetByIdBadRequest,
DefaultResponsesParams,
DefaultResponsesDefault,
} from './generated/resources/PetsResource'
import { Pet } from './generated/types'
import { SlushyContext } from '@slushy/server'
import { Context } from './Context'

export class PetsResourceImpl implements PetsResource<Context> {
private pets: Pet[] = [{ id: 1, name: 'Pet 1' }]
Expand All @@ -27,7 +27,7 @@ export class PetsResourceImpl implements PetsResource<Context> {
}

public async getPetById(params: GetPetByIdParams): Promise<GetPetByIdResponse> {
const pet = this.pets.filter(pet => pet.id === params.petId)[0]
const pet = this.pets.filter(_pet => _pet.id === params.petId)[0]
if (!pet) {
throw new GetPetByIdBadRequest({ message: 'No pet found.' })
}
Expand All @@ -46,14 +46,14 @@ export class PetsResourceImpl implements PetsResource<Context> {

public async uploadPetPicture(
_params: UploadPetPictureParams,
_context: SlushyContext<Context>
_context: SlushyContext<Context>,
): Promise<UploadPetPictureResponse> {
return new UploadPetPictureOK()
}

public async defaultResponses(
_params: DefaultResponsesParams,
_context: SlushyContext<Context>
_context: SlushyContext<Context>,
): Promise<DefaultResponsesDefault> {
throw new DefaultResponsesDefault(401, { errors: [{ message: 'This is a generic error' }] })
}
Expand Down
10 changes: 10 additions & 0 deletions example/src/ValidationResourceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
ValidationQueryParams,
ValidationQueryResponse,
ValidationResource,
ValidationResponseOK,
ValidationResponseParams,
ValidationResponseResponse,
} from './generated/resources/ValidationResource'

export class ValidationResourceImpl implements ValidationResource<Context> {
Expand Down Expand Up @@ -47,4 +50,11 @@ export class ValidationResourceImpl implements ValidationResource<Context> {
): Promise<ValidationHeaderResponse> {
return new ValidationHeaderOK({ header: params['x-header'] })
}

public async validationResponse(
_params: ValidationResponseParams,
_context: SlushyContext<Context>,
): Promise<ValidationResponseResponse> {
return new ValidationResponseOK({ data: { notAString: true } as any })
}
}
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '__tests__.*.spec.[jt]sx?$',
}
69 changes: 69 additions & 0 deletions server/src/ResponseValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Ajv from 'ajv'
import { OpenAPIV3 } from 'openapi-types'
import { ResponseValidationError } from './errors'
import { isReferenceObject } from './helpers/isReferenceObject'
import { SlushyProps } from './SlushyProps'

export class ResponseValidator {
private readonly validator: Ajv.Ajv

public constructor(_props: SlushyProps<any>) {
this.validator = new Ajv({
allErrors: true,
useDefaults: true,
unknownFormats: 'ignore',
logger: false,
})
}

public validateResponse(
status: number,
payload: any,
contentType: string,
operationObject: OpenAPIV3.OperationObject,
) {
// FIXME: Validate more content types
if (contentType !== 'application/json') {
return
}

if (!operationObject.responses) {
// No responses expected
return
}

// FIXME: Handle status code range
const response = operationObject.responses[status.toString()] || operationObject.responses.default
if (!response) {
// Status code not expected
return
}

if (isReferenceObject(response)) {
// Should be resolved
return
}

if (!response.content) {
if (payload != null) {
throw new ResponseValidationError('Invalid response content, no content expected')
}
// No content expected
return
}

if (!response.content[contentType]) {
throw new ResponseValidationError('Invalid content type')
}

const schema = response.content[contentType].schema
if (!schema) {
return
}

const isValid = this.validator.validate(schema, payload)
if (!isValid && this.validator.errors) {
throw new ResponseValidationError('Invalid response content, validation failed', this.validator.errors)
}
}
}
7 changes: 7 additions & 0 deletions server/src/SlushyConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ export interface SlushyConfig<TContext> {
getRequestId?: (req: SlushyRequest) => string
transformError?: (error: unknown, req: SlushyRequest) => any
loggerFactory?: LoggerFactory
basePath?: string
docs?: {
/**
* Host interactive Api documentation on this path.
*/
path?: string
}
}
Loading

0 comments on commit 26a0034

Please sign in to comment.