Skip to content

Commit f085474

Browse files
lgestcclaude
andauthored
[Cases] Re-implement runtime types in zod (elastic#258427)
## Summary This is stage 1 of the migration, see [[Epic] [Cases] Migrate away from io-ts](elastic/security-team#16437) This implements all io-ts types as zod schemas; a stepping stone towards full migration None of the types are used for validation yet, they will be enforced in future PR's that will follow this one. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c83fd87 commit f085474

61 files changed

Lines changed: 4373 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { z } from '@kbn/zod/v4';
9+
import { ALLOWED_MIME_TYPES } from '../constants/mime_types';
10+
11+
export interface LimitedSchemaType {
12+
fieldName: string;
13+
min: number;
14+
max: number;
15+
}
16+
17+
export const NonEmptyString = z.string().min(1);
18+
19+
export const limitedStringSchema = ({ fieldName, min, max }: LimitedSchemaType) =>
20+
z.string().superRefine((s, ctx) => {
21+
const trimmed = s.trim();
22+
23+
if (trimmed.length === 0) {
24+
ctx.addIssue({
25+
code: z.ZodIssueCode.custom,
26+
message: `The ${fieldName} field cannot be an empty string.`,
27+
});
28+
return;
29+
}
30+
31+
if (trimmed.length < min) {
32+
ctx.addIssue({
33+
code: z.ZodIssueCode.custom,
34+
message: `The length of the ${fieldName} is too short. The minimum length is ${min}.`,
35+
});
36+
return;
37+
}
38+
39+
if (trimmed.length > max) {
40+
ctx.addIssue({
41+
code: z.ZodIssueCode.custom,
42+
message: `The length of the ${fieldName} is too long. The maximum length is ${max}.`,
43+
});
44+
}
45+
});
46+
47+
export const limitedArraySchema = <T extends z.ZodTypeAny>({
48+
codec,
49+
fieldName,
50+
min,
51+
max,
52+
}: { codec: T } & LimitedSchemaType) =>
53+
z.array(codec).superRefine((s, ctx) => {
54+
if (s.length < min) {
55+
ctx.addIssue({
56+
code: z.ZodIssueCode.custom,
57+
message: `The length of the field ${fieldName} is too short. Array must be of length >= ${min}.`,
58+
});
59+
return;
60+
}
61+
62+
if (s.length > max) {
63+
ctx.addIssue({
64+
code: z.ZodIssueCode.custom,
65+
message: `The length of the field ${fieldName} is too long. Array must be of length <= ${max}.`,
66+
});
67+
}
68+
});
69+
70+
export const limitedNumberSchema = ({ fieldName, min, max }: LimitedSchemaType) =>
71+
z.number().superRefine((s, ctx) => {
72+
if (s < min) {
73+
ctx.addIssue({
74+
code: z.ZodIssueCode.custom,
75+
message: `The ${fieldName} field cannot be less than ${min}.`,
76+
});
77+
return;
78+
}
79+
80+
if (s > max) {
81+
ctx.addIssue({
82+
code: z.ZodIssueCode.custom,
83+
message: `The ${fieldName} field cannot be more than ${max}.`,
84+
});
85+
}
86+
});
87+
88+
export const paginationSchema = ({ maxPerPage }: { maxPerPage: number }) => {
89+
const pageCoerce = z.union([z.number(), z.string().transform((s) => Number(s))]);
90+
return z.object({
91+
page: pageCoerce.optional(),
92+
perPage: pageCoerce.optional(),
93+
});
94+
};
95+
96+
export const limitedNumberAsIntegerSchema = ({ fieldName }: { fieldName: string }) =>
97+
z.number().superRefine((s, ctx) => {
98+
if (!Number.isSafeInteger(s)) {
99+
ctx.addIssue({
100+
code: z.ZodIssueCode.custom,
101+
message: `The ${fieldName} field should be an integer between -(2^53 - 1) and 2^53 - 1, inclusive.`,
102+
});
103+
}
104+
});
105+
106+
export const regexStringSchema = ({
107+
codec,
108+
pattern,
109+
message,
110+
}: {
111+
codec: z.ZodType<string>;
112+
pattern: string;
113+
message: string;
114+
}) =>
115+
codec.superRefine((value, ctx) => {
116+
if (!new RegExp(pattern).test(value)) {
117+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
118+
}
119+
});
120+
121+
export const mimeTypeString = z.string().superRefine((s, ctx) => {
122+
if (!ALLOWED_MIME_TYPES.includes(s)) {
123+
ctx.addIssue({
124+
code: z.ZodIssueCode.custom,
125+
message: `The mime type field value ${s} is not allowed.`,
126+
});
127+
}
128+
});
129+
130+
/**
131+
* Zod equivalent of jsonValueRt — a recursive JSON value type.
132+
*/
133+
export type JsonValue =
134+
| string
135+
| number
136+
| boolean
137+
| null
138+
| JsonValue[]
139+
| { [key: string]: JsonValue };
140+
141+
export const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
142+
z.union([
143+
z.string(),
144+
z.number(),
145+
z.boolean(),
146+
z.null(),
147+
z.array(jsonValueSchema),
148+
z.record(z.string(), jsonValueSchema),
149+
])
150+
);

x-pack/platform/plugins/shared/cases/common/types/api/attachment/v1.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ import {
2323
FindAttachmentsQueryParamsRt,
2424
PostFileAttachmentRequestRt,
2525
} from './v1';
26+
import {
27+
AttachmentPatchRequestSchema,
28+
AttachmentRequestSchema,
29+
AttachmentsFindResponseSchema,
30+
BulkCreateAttachmentsRequestSchema,
31+
BulkDeleteFileAttachmentsRequestSchema,
32+
BulkGetAttachmentsRequestSchema,
33+
BulkGetAttachmentsResponseSchema,
34+
FindAttachmentsQueryParamsSchema,
35+
PostFileAttachmentRequestSchema,
36+
} from '../../api_zod/attachment/v1';
2637

2738
describe('Attachments', () => {
2839
describe('BulkDeleteFileAttachmentsRequestRt', () => {
@@ -46,6 +57,21 @@ describe('Attachments', () => {
4657
right: { ids: ['abc', 'xyz'] },
4758
});
4859
});
60+
61+
it('zod: has expected attributes in request', () => {
62+
const result = BulkDeleteFileAttachmentsRequestSchema.safeParse({ ids: ['abc', 'xyz'] });
63+
expect(result.success).toBe(true);
64+
expect(result.data).toStrictEqual({ ids: ['abc', 'xyz'] });
65+
});
66+
67+
it('zod: strips unknown fields', () => {
68+
const result = BulkDeleteFileAttachmentsRequestSchema.safeParse({
69+
ids: ['abc', 'xyz'],
70+
foo: 'bar',
71+
});
72+
expect(result.success).toBe(true);
73+
expect(result.data).toStrictEqual({ ids: ['abc', 'xyz'] });
74+
});
4975
});
5076

5177
describe('AttachmentRequestRt', () => {
@@ -73,6 +99,18 @@ describe('Attachments', () => {
7399
});
74100
});
75101

102+
it('zod: has expected attributes in request', () => {
103+
const result = AttachmentRequestSchema.safeParse(defaultRequest);
104+
expect(result.success).toBe(true);
105+
expect(result.data).toStrictEqual(defaultRequest);
106+
});
107+
108+
it('zod: strips unknown fields', () => {
109+
const result = AttachmentRequestSchema.safeParse({ ...defaultRequest, foo: 'bar' });
110+
expect(result.success).toBe(true);
111+
expect(result.data).toStrictEqual(defaultRequest);
112+
});
113+
76114
describe('errors', () => {
77115
describe('commentType: user', () => {
78116
it('throws error when comment is too long', () => {
@@ -166,6 +204,18 @@ describe('Attachments', () => {
166204
right: defaultRequest,
167205
});
168206
});
207+
208+
it('zod: has expected attributes in request', () => {
209+
const result = AttachmentPatchRequestSchema.safeParse(defaultRequest);
210+
expect(result.success).toBe(true);
211+
expect(result.data).toStrictEqual(defaultRequest);
212+
});
213+
214+
it('zod: strips unknown fields', () => {
215+
const result = AttachmentPatchRequestSchema.safeParse({ ...defaultRequest, foo: 'bar' });
216+
expect(result.success).toBe(true);
217+
expect(result.data).toStrictEqual(defaultRequest);
218+
});
169219
});
170220

171221
describe('AttachmentsFindResponseRt', () => {
@@ -222,6 +272,18 @@ describe('Attachments', () => {
222272
right: defaultRequest,
223273
});
224274
});
275+
276+
it('zod: has expected attributes in request', () => {
277+
const result = AttachmentsFindResponseSchema.safeParse(defaultRequest);
278+
expect(result.success).toBe(true);
279+
expect(result.data).toStrictEqual(defaultRequest);
280+
});
281+
282+
it('zod: strips unknown fields', () => {
283+
const result = AttachmentsFindResponseSchema.safeParse({ ...defaultRequest, foo: 'bar' });
284+
expect(result.success).toBe(true);
285+
expect(result.data).toStrictEqual(defaultRequest);
286+
});
225287
});
226288

227289
describe('FindAttachmentsQueryParamsRt', () => {
@@ -248,6 +310,18 @@ describe('Attachments', () => {
248310
right: defaultRequest,
249311
});
250312
});
313+
314+
it('zod: has expected attributes in request', () => {
315+
const result = FindAttachmentsQueryParamsSchema.safeParse(defaultRequest);
316+
expect(result.success).toBe(true);
317+
expect(result.data).toStrictEqual(defaultRequest);
318+
});
319+
320+
it('zod: strips unknown fields', () => {
321+
const result = FindAttachmentsQueryParamsSchema.safeParse({ ...defaultRequest, foo: 'bar' });
322+
expect(result.success).toBe(true);
323+
expect(result.data).toStrictEqual(defaultRequest);
324+
});
251325
});
252326

253327
describe('BulkCreateAttachmentsRequestRt', () => {
@@ -279,6 +353,20 @@ describe('Attachments', () => {
279353
});
280354
});
281355

356+
it('zod: has expected attributes in request', () => {
357+
const result = BulkCreateAttachmentsRequestSchema.safeParse(defaultRequest);
358+
expect(result.success).toBe(true);
359+
expect(result.data).toStrictEqual(defaultRequest);
360+
});
361+
362+
it('zod: strips unknown fields', () => {
363+
const result = BulkCreateAttachmentsRequestSchema.safeParse([
364+
{ comment: 'Solve this fast!', type: AttachmentType.user, owner: 'cases', foo: 'bar' },
365+
]);
366+
expect(result.success).toBe(true);
367+
expect(result.data).toStrictEqual(defaultRequest);
368+
});
369+
282370
describe('errors', () => {
283371
it(`throws error when attachments are more than ${MAX_BULK_CREATE_ATTACHMENTS}`, () => {
284372
const comment = {
@@ -319,6 +407,21 @@ describe('Attachments', () => {
319407
right: { ids: ['abc', 'xyz'] },
320408
});
321409
});
410+
411+
it('zod: has expected attributes in request', () => {
412+
const result = BulkGetAttachmentsRequestSchema.safeParse({ ids: ['abc', 'xyz'] });
413+
expect(result.success).toBe(true);
414+
expect(result.data).toStrictEqual({ ids: ['abc', 'xyz'] });
415+
});
416+
417+
it('zod: strips unknown fields', () => {
418+
const result = BulkGetAttachmentsRequestSchema.safeParse({
419+
ids: ['abc', 'xyz'],
420+
foo: 'bar',
421+
});
422+
expect(result.success).toBe(true);
423+
expect(result.data).toStrictEqual({ ids: ['abc', 'xyz'] });
424+
});
322425
});
323426

324427
describe('BulkGetAttachmentsResponseRt', () => {
@@ -393,6 +496,18 @@ describe('Attachments', () => {
393496
right: defaultRequest,
394497
});
395498
});
499+
500+
it('zod: has expected attributes in request', () => {
501+
const result = BulkGetAttachmentsResponseSchema.safeParse(defaultRequest);
502+
expect(result.success).toBe(true);
503+
expect(result.data).toStrictEqual(defaultRequest);
504+
});
505+
506+
it('zod: strips unknown fields', () => {
507+
const result = BulkGetAttachmentsResponseSchema.safeParse({ ...defaultRequest, foo: 'bar' });
508+
expect(result.success).toBe(true);
509+
expect(result.data).toStrictEqual(defaultRequest);
510+
});
396511
});
397512

398513
describe('PostFileAttachmentRequestRt', () => {
@@ -419,6 +534,18 @@ describe('Attachments', () => {
419534
});
420535
});
421536

537+
it('zod: has expected attributes in request', () => {
538+
const result = PostFileAttachmentRequestSchema.safeParse(defaultRequest);
539+
expect(result.success).toBe(true);
540+
expect(result.data).toStrictEqual(defaultRequest);
541+
});
542+
543+
it('zod: strips unknown fields', () => {
544+
const result = PostFileAttachmentRequestSchema.safeParse({ ...defaultRequest, foo: 'bar' });
545+
expect(result.success).toBe(true);
546+
expect(result.data).toStrictEqual(defaultRequest);
547+
});
548+
422549
describe('errors', () => {
423550
it('throws an error when the filename is too long', () => {
424551
const longFilename = 'x'.repeat(MAX_FILENAME_LENGTH + 1);

0 commit comments

Comments
 (0)