Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
"test": "yarn test:ts",
"test:common": "NODE_OPTIONS='--enable-source-maps --experimental-vm-modules' jest",
"test:ts": "yarn test:common -config=./test/config/jest.config.js --coverage --passWithNoTests",
"ts": "tsc --noEmit --watch",
"coverage:permissions": "chmod -R 777 ./coverage"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@hapi/joi": "17.1.1"
"zod": "3.23.8"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
Expand All @@ -36,7 +37,6 @@
"@semantic-release/github": "^11.0.0",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.1",
"@types/hapi__joi": "^17.1.14",
"jest": "^29.7.0",
"semantic-release": "^24.1.2",
"ts-jest": "^29.2.5",
Expand Down
35 changes: 16 additions & 19 deletions packages/core/src/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import Joi from '@hapi/joi'
import { z } from 'zod'

export type ConfigurationObjectType = {
[key: string]: {
[key: string]: string
}
}
const ConfigurationObjectSchema = z.record(z.record(z.string()))

export type ConfigurationType = {
cors: null | '*'
headers: ConfigurationObjectType
query: ConfigurationObjectType
cookies: ConfigurationObjectType
}
const ConfigurationSchema = z
.object({
cors: z.union([z.literal('*'), z.literal(null)]),
headers: z.record(z.record(z.string())),
query: z.record(z.record(z.string())),
cookies: z.record(z.record(z.string())),
})
.strict()

const ConfigurationPartialSchema = ConfigurationSchema.partial()

const schema = Joi.object({
cors: Joi.alternatives([Joi.string().valid('*'), Joi.object().valid(null)]),
headers: Joi.object(),
query: Joi.object(),
cookies: Joi.object(),
}).required()
export type ConfigurationObjectType = z.infer<typeof ConfigurationObjectSchema>
export type ConfigurationPartialType = z.infer<typeof ConfigurationPartialSchema>
export type ConfigurationType = z.infer<typeof ConfigurationSchema>

export function validateConfiguration(unsafeConfiguration: unknown) {
return schema.validate(unsafeConfiguration).error
return ConfigurationPartialSchema.safeParse(unsafeConfiguration)
}

export function createConfiguration(): ConfigurationType {
Expand Down
191 changes: 33 additions & 158 deletions packages/core/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,22 @@
import { URLSearchParams } from 'node:url'
import { hash, isObjectEmpty, type NonEmptyArray, sortObjectKeysRecurs } from './utils.js'
// TODO: remove Joi with a lightweight composable validation library
import Joi from '@hapi/joi'
import type { ConfigurationType } from './configuration.js'
import type { z } from 'zod'
import {
type FixtureRequestOptionsSchema,
type FixtureRequestSchema,
type FixtureResponseSchema,
FixtureSchema,
} from './schema.js'

export type FixtureStorageType = {
storage: Map<string, NormalizedFixtureType>
}

export type FixtureRequestOptionsType = null | {
// TODO: test and document this
origin?: {
allowRegex?: boolean
}
path?: {
allowRegex?: boolean
disableEncodeURI?: boolean
}
method?: {
allowRegex?: boolean
}
headers?: {
strict?: boolean
allowRegex?: boolean
}
cookies?: {
strict?: boolean
allowRegex?: boolean
}
query?: {
strict?: boolean
allowRegex?: boolean
}
body?: {
strict?: boolean
allowRegex?: boolean
}
}

export type FixtureRequestType = {
origin: string
path: string
method: string
headers?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
cookies?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
query?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
body?: null | { [key: string]: unknown }
options?: FixtureRequestOptionsType
}

export type FixtureResponseType = {
status?: number
headers?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
cookies?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
query?: null | { [key: string]: string } | ({ [key: string]: string } | string)[]
body?: null | { [key: string]: unknown }
filepath?: string
options?: null | {
delay?: number
lifetime?: number
}
}

export type FixtureType =
| {
request: FixtureRequestType
response: FixtureResponseType
}
| {
request: FixtureRequestType
responses: NonEmptyArray<FixtureResponseType>
}
export type FixtureRequestOptionsType = null | z.infer<typeof FixtureRequestOptionsSchema>
export type FixtureRequestType = z.infer<typeof FixtureRequestSchema>
export type FixtureResponseType = z.infer<typeof FixtureResponseSchema>
export type FixtureType = z.infer<typeof FixtureSchema>

export type NormalizedFixtureRequestType = {
origin: string
Expand Down Expand Up @@ -107,101 +53,30 @@ export function createFixtureStorage() {
}
}

export function validateFixture(
unsafeFixture: unknown,
configuration: ConfigurationType,
): [null, string] | [FixtureType, string] {
const schemaProperty = Joi.alternatives([
Joi.array().items(
Joi.custom((value, helpers) => {
const path = helpers.state.path
const property = path?.[path.length - 2]

if (property === 'headers' || property === 'query' || property === 'cookies') {
if (!configuration[property]?.[value]) {
throw new Error(`${value} not found in configuration`)
}
}

return value
}),
Joi.object(),
),
Joi.object(),
])

const optionsStrictOrAllowRegex = Joi.object({
strict: Joi.bool(),
allowRegex: Joi.bool(),
}).invalid({ strict: true, allowRegex: true })

const requestSchema = Joi.object({
body: Joi.any(),
origin: Joi.string().required(),
path: Joi.string().required(),
method: Joi.alternatives([
Joi.string().regex(/^(head|delete|put|post|get|options|patch|\*)$/i),
Joi.custom((value, helpers) => {
const options = helpers.state.ancestors[0].options || {}
const allowMethodRegex = options.method?.allowRegex

if (!allowMethodRegex) {
throw new Error(`Method ${value} is not a valid method`)
}
export function validateFixture(unsafeFixture: unknown, configuration: ConfigurationType) {
const refineArrayWithConfiguration = (
property: 'headers' | 'query' | 'cookies',
): [(data: FixtureType) => boolean | true, { message: string }] => [
(data: FixtureType) => {
const propertyValue = data.request[property]

if (typeof value !== 'string') {
return helpers.error('string.invalid')
}
if (Array.isArray(propertyValue)) {
return propertyValue.every((value) =>
typeof value === 'string' ? configuration[property][value] !== undefined : true,
)
}

return value
}),
]).required(),
headers: schemaProperty,
cookies: schemaProperty,
query: schemaProperty,
options: Joi.object({
path: Joi.object({
allowRegex: Joi.bool(),
disableEncodeURI: Joi.bool(),
}).invalid({ allowRegex: true, disableEncodeURI: true }),
method: Joi.object({
allowRegex: Joi.bool(),
}),
headers: optionsStrictOrAllowRegex,
cookies: optionsStrictOrAllowRegex,
query: optionsStrictOrAllowRegex,
body: optionsStrictOrAllowRegex,
}),
})

const responseSchema = Joi.object({
status: Joi.number().integer().min(200).max(600),
body: Joi.any(),
filepath: Joi.string(),
headers: schemaProperty,
cookies: schemaProperty,
options: Joi.object({
delay: Joi.number().integer().min(0),
lifetime: Joi.number().integer().min(0),
}),
}).oxor('body', 'filepath')

const schema = Joi.object({
request: requestSchema.required(),
response: responseSchema,
responses: Joi.array().items(responseSchema),
})
.or('response', 'responses')
.required()

const error = schema.validate(unsafeFixture).error

if (error) {
return [null, error.message]
}
return true
},
{
message: `request.${property} contains a value not in the configuration`,
},
]

// Use "as" temporarily until new validation lib like Zod
return [unsafeFixture as FixtureType, '']
return FixtureSchema.refine(...refineArrayWithConfiguration('headers'))
.refine(...refineArrayWithConfiguration('query'))
.refine(...refineArrayWithConfiguration('cookies'))
.safeParse(unsafeFixture)
}

function normalizeArrayMatcher(
Expand Down Expand Up @@ -329,7 +204,7 @@ function normalizeFixture(fixture: FixtureType, configuration: ConfigurationType
}
}

function createFixtureId(fixture: FixtureType) {
function createFixtureId(fixture: NormalizedFixtureType) {
return hash(JSON.stringify(fixture.request))
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ export * from './response.js'
export * from './service.js'
export * from './fixtures.js'
export * from './configuration.js'
export { FixtureResponseSchema } from './schema.js'
export { FixtureRequestSchema } from './schema.js'
export { RecordOrArrayWithConfigurationSchema } from './schema.js'
export { FixtureRequestOptionsSchema } from './schema.js'
Loading
Loading