Skip to content

Commit

Permalink
feat: generator schema migration (#373)
Browse files Browse the repository at this point in the history
  • Loading branch information
cristianoventura authored Dec 30, 2024
1 parent 01bff4a commit 1c2d87d
Show file tree
Hide file tree
Showing 23 changed files with 395 additions and 26 deletions.
2 changes: 1 addition & 1 deletion src/codegen/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('Code generation', () => {
generateScript({
recording: [],
generator: {
version: '0',
version: '1.0',
recordingPath: 'test',
options: {
loadProfile: {
Expand Down
15 changes: 0 additions & 15 deletions src/schemas/generator.ts

This file was deleted.

25 changes: 25 additions & 0 deletions src/schemas/generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generator migration

Migrations are needed when changing the structure of existing generators file such as renaming or deleting keys.

If you're simply adding new options to the generator file, a migration may not be needed. In this case, extend the latest schema available.

## Creating a new migration

For when a new migration is needed:

1. Create a directory for the new version (e.g. `/v2`).
2. Copy files from the previous schema into this directory and make appropriate changes.
3. In `/v1/index.ts`, implement a `migrate` function that takes a `v1` schema and returns a `v2` schema.
4. Update `/schemas/generators/index.ts` to use the new version:

```ts
function migrate(generator: z.infer<typeof AnyGeneratorSchema>) {

case '1.0':
return migrate(v1.migrate(generator))

}
```

5. Make changes to the remaining implementation to use the new schema.
35 changes: 35 additions & 0 deletions src/schemas/generator/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { migrate } from '.'
import * as v0 from './v0'

describe('Generator migration', () => {
it('should migrate from v0 to latest', () => {
const v0Generator: v0.GeneratorSchema = {
version: '0',
recordingPath: 'test',
options: {
loadProfile: {
executor: 'shared-iterations',
vus: 1,
iterations: 1,
},
thinkTime: {
sleepType: 'iterations',
timing: {
type: 'fixed',
value: 1,
},
},
},
testData: {
variables: [],
},
rules: [],
allowlist: [],
includeStaticAssets: false,
scriptName: 'my-script.js',
}

expect(migrate(v0Generator).version).toBe('1.0')
})
})
26 changes: 26 additions & 0 deletions src/schemas/generator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod'
import * as v0 from './v0'
import * as v1 from './v1'
import { exhaustive } from '../../utils/typescript'

const AnyGeneratorSchema = z.discriminatedUnion('version', [
v0.GeneratorFileDataSchema,
v1.GeneratorFileDataSchema,
])

export function migrate(generator: z.infer<typeof AnyGeneratorSchema>) {
switch (generator.version) {
case '0':
return migrate(v0.migrate(generator))
case '1.0':
return generator
default:
return exhaustive(generator)
}
}

export const GeneratorFileDataSchema = AnyGeneratorSchema.transform(migrate)

export * from './v1/rules'
export * from './v1/testData'
export * from './v1/testOptions'
32 changes: 32 additions & 0 deletions src/schemas/generator/v0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { TestRuleSchema } from './rules'
import { TestDataSchema } from './testData'
import { TestOptionsSchema } from './testOptions'
import * as v1 from '../v1'

export const GeneratorFileDataSchema = z.object({
version: z.literal('0'),
recordingPath: z.string(),
options: TestOptionsSchema,
testData: TestDataSchema,
rules: TestRuleSchema.array(),
allowlist: z.string().array(),
includeStaticAssets: z.boolean(),
scriptName: z.string().default('my-script.js'),
})

export type GeneratorSchema = z.infer<typeof GeneratorFileDataSchema>

// Migrate generator to the next version
export function migrate(generator: GeneratorSchema): v1.GeneratorSchema {
return {
version: '1.0',
allowlist: generator.allowlist,
includeStaticAssets: generator.includeStaticAssets,
options: generator.options,
recordingPath: generator.recordingPath,
rules: generator.rules,
scriptName: generator.scriptName,
testData: generator.testData,
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
22 changes: 22 additions & 0 deletions src/schemas/generator/v1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from 'zod'
import { TestRuleSchema } from './rules'
import { TestDataSchema } from './testData'
import { TestOptionsSchema } from './testOptions'

export const GeneratorFileDataSchema = z.object({
version: z.literal('1.0'),
recordingPath: z.string(),
options: TestOptionsSchema,
testData: TestDataSchema,
rules: TestRuleSchema.array(),
allowlist: z.string().array(),
includeStaticAssets: z.boolean(),
scriptName: z.string().default('my-script.js'),
})

export type GeneratorSchema = z.infer<typeof GeneratorFileDataSchema>

// TODO: Migrate generator to the next version
export function migrate(generator: z.infer<typeof GeneratorFileDataSchema>) {
return { ...generator }
}
146 changes: 146 additions & 0 deletions src/schemas/generator/v1/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { z } from 'zod'

export const VariableValueSchema = z.object({
type: z.literal('variable'),
variableName: z.string(),
})

export const ArrayValueSchema = z.object({
type: z.literal('array'),
arrayName: z.string(),
})

export const CustomCodeValueSchema = z.object({
type: z.literal('customCode'),
code: z.string(),
})

export const RecordedValueSchema = z.object({
type: z.literal('recordedValue'),
})

export const StringValueSchema = z.object({
type: z.literal('string'),
value: z.string(),
})

export const FilterSchema = z.object({
path: z.string(),
})

export const BeginEndSelectorSchema = z.object({
type: z.literal('begin-end'),
from: z.enum(['headers', 'body', 'url']),
begin: z.string(),
end: z.string(),
})

export const HeaderNameSelectorSchema = z.object({
type: z.literal('header-name'),
from: z.enum(['headers']),
name: z.string(),
})

export const RegexSelectorSchema = z.object({
type: z.literal('regex'),
from: z.enum(['headers', 'body', 'url']),
regex: z.string().refine(
(value) => {
try {
new RegExp(value)
return true
} catch {
return false
}
},
{ message: 'Invalid regular expression' }
),
})

export const JsonSelectorSchema = z.object({
type: z.literal('json'),
from: z.literal('body'),
path: z.string(),
})

export const CustomCodeSelectorSchema = z.object({
type: z.literal('custom-code'),
snippet: z.string(),
})

export const StatusCodeSelectorSchema = z.object({
type: z.literal('status-code'),
})

export const SelectorSchema = z.discriminatedUnion('type', [
BeginEndSelectorSchema,
RegexSelectorSchema,
JsonSelectorSchema,
HeaderNameSelectorSchema,
])

export const VerificationRuleSelectorSchema = z.discriminatedUnion('type', [
BeginEndSelectorSchema,
RegexSelectorSchema,
JsonSelectorSchema,
])

export const CorrelationExtractorSchema = z.object({
filter: FilterSchema,
selector: SelectorSchema,
variableName: z.string().optional(),
})

export const CorrelationReplacerSchema = z.object({
filter: FilterSchema,
selector: SelectorSchema.optional(),
})

export const RuleBaseSchema = z.object({
id: z.string(),
enabled: z.boolean().default(true),
})

export const ParameterizationRuleSchema = RuleBaseSchema.extend({
type: z.literal('parameterization'),
filter: FilterSchema,
selector: SelectorSchema,
value: z.discriminatedUnion('type', [
VariableValueSchema,
ArrayValueSchema,
CustomCodeValueSchema,
StringValueSchema,
]),
})

export const CorrelationRuleSchema = RuleBaseSchema.extend({
type: z.literal('correlation'),
extractor: CorrelationExtractorSchema,
replacer: CorrelationReplacerSchema.optional(),
})

export const VerificationRuleSchema = RuleBaseSchema.extend({
type: z.literal('verification'),
filter: FilterSchema,
selector: VerificationRuleSelectorSchema.optional(),
value: z.discriminatedUnion('type', [
VariableValueSchema,
ArrayValueSchema,
CustomCodeValueSchema,
RecordedValueSchema,
]),
})

export const CustomCodeRuleSchema = RuleBaseSchema.extend({
type: z.literal('customCode'),
filter: FilterSchema,
placement: z.enum(['before', 'after']),
snippet: z.string(),
})

export const TestRuleSchema = z.discriminatedUnion('type', [
ParameterizationRuleSchema,
CorrelationRuleSchema,
VerificationRuleSchema,
CustomCodeRuleSchema,
])
29 changes: 29 additions & 0 deletions src/schemas/generator/v1/testData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from 'zod'

export const VariableSchema = z.object({
name: z
.string()
.min(1, { message: 'Required' })
.regex(/^[a-zA-Z0-9_]*$/, { message: 'Invalid name' })
// Don't allow native object properties, like __proto__, valueOf, etc.
.refine((val) => !(val in {}), { message: 'Invalid name' }),
value: z.string(),
})

export const TestDataSchema = z.object({
variables: VariableSchema.array().superRefine((variables, ctx) => {
const names = variables.map((variable) => variable.name)

const duplicateIndex = variables.findIndex(
(item, index) => names.indexOf(item.name) !== index
)

if (duplicateIndex !== -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Variable names must be unique',
path: [duplicateIndex, 'name'],
})
}
}),
})
Loading

0 comments on commit 1c2d87d

Please sign in to comment.