From 44fc44a85201416ccde3b1b9a5c6c67f502abe8c Mon Sep 17 00:00:00 2001 From: Jan Macku Date: Sat, 11 Mar 2023 19:40:32 +0100 Subject: [PATCH 1/2] refactor: use Zod instead of our own validation system --- package.json | 4 +- src/index.ts | 62 ++-- src/link.ts | 68 ++-- src/types.ts | 841 +++++++++++++++++++--------------------------- test/link.test.ts | 20 +- yarn.lock | 16 + 6 files changed, 443 insertions(+), 568 deletions(-) diff --git a/package.json b/package.json index 2a0aa93..f801f32 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ }, "dependencies": { "axios": "1.6.8", - "luxon": "3.4.4" + "js-base64": "3.7.5", + "luxon": "3.4.4", + "zod": "3.21.4" }, "packageManager": "yarn@4.2.2" } diff --git a/src/index.ts b/src/index.ts index 378dfc0..fca0853 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -import { URL, URLSearchParams } from 'url'; - import type { DateTime } from 'luxon'; +import { URL, URLSearchParams } from 'url'; +import { z } from 'zod'; import type { BugzillaLink, SearchParams } from './link'; import { PublicLink, params, PasswordLink, ApiKeyLink } from './link'; @@ -20,19 +20,18 @@ import type { UpdatedAttachment, } from './types'; import { - HistoryLookupSpec, - BugSpec, - UserSpec, - VersionSpec, - CommentsSpec, - CreatedCommentSpec, - CreatedBugSpec, - UpdatedBugTemplateSpec, - AttachmentsSpec, - CreatedAttachmentSpec, - UpdatedAttachmentTemplateSpec, + historyLookupSchema, + bugSchema, + userSchema, + versionSchema, + commentsSchema, + createdCommentSchema, + createdBugSchema, + updatedBugTemplateSchema, + attachmentsSchema, + createdAttachmentSchema, + updatedAttachmentTemplateSchema, } from './types'; -import { array, object } from './validators'; export type { Bug, @@ -73,13 +72,13 @@ export default class BugzillaAPI { } public async version(): Promise { - let version = await this.link.get('version', object(VersionSpec)); + let version = await this.link.get('version', versionSchema); return version.version; } public whoami(): Promise { - return this.link.get('whoami', object(UserSpec)); + return this.link.get('whoami', userSchema); } public async bugHistory( @@ -95,7 +94,7 @@ export default class BugzillaAPI { let bugs = await this.link.get( `bug/${bugId}/history`, - object(HistoryLookupSpec), + historyLookupSchema, searchParams, ); @@ -123,9 +122,13 @@ export default class BugzillaAPI { let result = await this.link.get( 'bug', - object({ - bugs: array(object(BugSpec, includes, excludes)), + z.object({ + // TODO: pick includes and omit excludes | transform??? + bugs: z.array(bugSchema), }), + // object({ + // bugs: array(object(BugSpec, includes, excludes)), + // }), search, ); @@ -174,7 +177,7 @@ export default class BugzillaAPI { public async getComment(commentId: number): Promise { let comment = await this.link.get( `bug/comment/${commentId}`, - object(CommentsSpec), + commentsSchema, ); if (!comment) { @@ -185,10 +188,7 @@ export default class BugzillaAPI { } public async getBugComments(bugId: number): Promise { - let comments = await this.link.get( - `bug/${bugId}/comment`, - object(CommentsSpec), - ); + let comments = await this.link.get(`bug/${bugId}/comment`, commentsSchema); if (!comments) { throw new Error(`Failed to get comments of bug #${bugId}.`); @@ -209,7 +209,7 @@ export default class BugzillaAPI { let commentStatus = await this.link.post( `bug/${bugId}/comment`, - object(CreatedCommentSpec), + createdCommentSchema, content, ); @@ -221,7 +221,7 @@ export default class BugzillaAPI { } public async createBug(bug: CreateBugContent): Promise { - let bugStatus = await this.link.post('bug', object(CreatedBugSpec), bug); + let bugStatus = await this.link.post('bug', createdBugSchema, bug); if (!bugStatus) { throw new Error('Failed to create bug.'); @@ -236,7 +236,7 @@ export default class BugzillaAPI { ): Promise { let response = await this.link.put( `bug/${bugIdOrAlias}`, - object(UpdatedBugTemplateSpec), + updatedBugTemplateSchema, data, ); @@ -252,7 +252,7 @@ export default class BugzillaAPI { ): Promise { let attachment = await this.link.get( `bug/attachment/${attachmentId}`, - object(AttachmentsSpec), + attachmentsSchema, ); if (!attachment) { @@ -267,7 +267,7 @@ export default class BugzillaAPI { ): Promise { let attachments = await this.link.get( `bug/${bugId}/attachment`, - object(AttachmentsSpec), + attachmentsSchema, ); if (!attachments) { @@ -287,7 +287,7 @@ export default class BugzillaAPI { let attachmentStatus = await this.link.post( `bug/${bugId}/attachment`, - object(CreatedAttachmentSpec), + createdAttachmentSchema, { ...attachment, ...dataBase64 }, ); @@ -304,7 +304,7 @@ export default class BugzillaAPI { ): Promise { let response = await this.link.put( `bug/attachment/${attachmentId}`, - object(UpdatedAttachmentTemplateSpec), + updatedAttachmentTemplateSchema, data, ); diff --git a/src/link.ts b/src/link.ts index c9a64d6..6b4d2e7 100644 --- a/src/link.ts +++ b/src/link.ts @@ -1,8 +1,8 @@ import { URLSearchParams, URL } from 'url'; +import { z } from 'zod'; import axios, { AxiosRequestConfig } from 'axios'; -import { object, Validator } from './validators'; -import { LoginResponseSpec } from './types'; +import { loginResponseSchema } from './types'; export type SearchParams = | Record @@ -28,10 +28,10 @@ function isError(payload: unknown): payload is ApiError { return payload && typeof payload == 'object' && payload.error; } -async function performRequest( +async function performRequest( config: AxiosRequestConfig, - validator: Validator, -): Promise { + schema: z.Schema, +): Promise { try { let response = await axios.request({ ...config, @@ -45,7 +45,7 @@ async function performRequest( throw new Error(response.data.message); } - return validator(response.data); + return schema.parse(response.data); } catch (e: unknown) { if (axios.isAxiosError(e)) { throw new Error(e.message); @@ -67,10 +67,10 @@ export abstract class BugzillaLink { this.instance = new URL('rest/', instance); } - protected abstract request( + protected abstract request( config: AxiosRequestConfig, - validator: Validator, - ): Promise; + schema: z.Schema, + ): Promise; protected buildURL(path: string, query?: SearchParams): URL { let url = new URL(path, this.instance); @@ -80,25 +80,25 @@ export abstract class BugzillaLink { return url; } - public async get( + public async get( path: string, - validator: Validator, + schema: z.Schema, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), }, - validator, + schema, ); } - public async post( + public async post( path: string, - validator: Validator, + schema: z.Schema, content: R, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), @@ -108,16 +108,16 @@ export abstract class BugzillaLink { 'Content-Type': 'application/json', }, }, - validator, + schema, ); } - public async put( + public async put( path: string, - validator: Validator, + schema: z.Schema, content: R, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), @@ -127,17 +127,17 @@ export abstract class BugzillaLink { 'Content-Type': 'application/json', }, }, - validator, + schema, ); } } export class PublicLink extends BugzillaLink { - protected async request( + protected async request( config: AxiosRequestConfig, - validator: Validator, - ): Promise { - return performRequest(config, validator); + schema: z.Schema, + ): Promise { + return performRequest(config, schema); } } @@ -152,10 +152,10 @@ export class ApiKeyLink extends BugzillaLink { super(instance); } - protected async request( + protected async request( config: AxiosRequestConfig, - validator: Validator, - ): Promise { + schema: z.Schema, + ): Promise { return performRequest( { ...config, @@ -166,7 +166,7 @@ export class ApiKeyLink extends BugzillaLink { Authorization: `Bearer ${this.apiKey}`, }, }, - validator, + schema, ); } } @@ -195,16 +195,16 @@ export class PasswordLink extends BugzillaLink { restrict_login: String(this.restrictLogin), }).toString(), }, - object(LoginResponseSpec), + loginResponseSchema, ); return loginInfo.token; } - protected async request( + protected async request( config: AxiosRequestConfig, - validator: Validator, - ): Promise { + schema: z.Schema, + ): Promise { if (!this.token) { this.token = await this.login(); } @@ -217,7 +217,7 @@ export class PasswordLink extends BugzillaLink { 'X-BUGZILLA-TOKEN': this.token, }, }, - validator, + schema, ); } } diff --git a/src/types.ts b/src/types.ts index 24829ec..c03e0aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,498 +1,355 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/no-redeclare */ -import type { DateTime } from 'luxon'; - -import { - int, - string, - array, - object, - boolean, - datetime, - nullable, - optional, - maybeArray, - ObjectSpec, - intString, - map, - double, - base64, -} from './validators'; - -type int = number; -type double = number; -type datetime = DateTime; - -export interface LoginResponse { - id: int; - token: string; +import { Base64 } from 'js-base64'; +import { z } from 'zod'; + +export const loginResponseSchema = z.object({ + id: z.number(), + token: z.string(), +}); + +export type LoginResponse = z.infer; + +export const versionSchema = z.object({ + version: z.string(), +}); + +export type Version = z.infer; + +export const userSchema = z.object({ + id: z.number(), + name: z.string(), + real_name: z.string(), +}); + +export type User = z.infer; + +export const setFlagSchema = z.object({ + status: z.string(), + name: z.string().optional(), + type_id: z.number().optional(), + requestee: z.string().optional(), +}); + +export type SetFlag = z.infer; + +export const updateFlagSchema = setFlagSchema.extend({ + id: z.number().optional(), + new: z.boolean().optional(), +}); + +export type UpdateFlag = z.infer; + +export const flagSchema = setFlagSchema.extend({ + id: z.number(), + creation_date: z.string().datetime(), + modification_date: z.string().datetime(), + setter: z.string(), +}); + +export type Flag = z.infer; + +export const bugSchema = z.object({ + alias: z.union([z.string(), z.array(z.string()), z.null()]), + assigned_to: z.string(), + assigned_to_detail: userSchema, + blocks: z.array(z.number()), + cc: z.array(z.string()), + cc_detail: z.array(userSchema), + classification: z.string(), + component: z.union([z.string(), z.array(z.string())]), + creation_time: z.string().datetime(), + creator: z.string(), + creator_detail: userSchema, + depends_on: z.array(z.number()), + dupe_of: z.union([z.number(), z.null(), z.null()]), + flags: z.union([z.array(flagSchema), z.undefined()]), + groups: z.array(z.string()), + id: z.number(), + is_cc_accessible: z.boolean(), + is_confirmed: z.boolean(), + is_open: z.boolean(), + is_creator_accessible: z.boolean(), + keywords: z.array(z.string()), + last_change_time: z.string().datetime(), + op_sys: z.string(), + platform: z.string(), + priority: z.string(), + product: z.string(), + qa_contact: z.string(), + qa_contact_detail: userSchema.optional(), + resolution: z.string(), + see_also: z.union([z.array(z.string()), z.undefined()]), + severity: z.string(), + status: z.string(), + summary: z.string(), + target_milestone: z.string(), + update_token: z.string().optional(), + url: z.string(), + version: z.union([z.string(), z.array(z.string())]), + whiteboard: z.string(), +}); + +export type Bug = z.infer; + +export const changeSchema = z.object({ + field_name: z.string(), + removed: z.string(), + added: z.string(), + attachment_id: z.number().optional(), +}); + +export type Change = z.infer; + +export const historySchema = z.object({ + when: z.string().datetime(), + who: z.string(), + changes: z.array(changeSchema), +}); + +export type History = z.infer; + +export const bugHistorySchema = z.object({ + id: z.number(), + alias: z.array(z.string()), + history: z.array(historySchema), +}); + +export type BugHistory = z.infer; + +export const historyLookupSchema = z.object({ + bugs: z.array(bugHistorySchema), +}); + +export type HistoryLookup = z.infer; + +export const commentSchema = z.object({ + attachment_id: z.union([z.number(), z.null()]).optional(), + bug_id: z.number(), + count: z.number(), + creation_time: z.string().datetime(), + creator: z.string(), + id: z.number(), + is_private: z.boolean(), + tags: z.array(z.string()), + time: z.string().datetime(), + text: z.string(), +}); + +export type Comment = z.infer; + +export const commentsTemplateSchema = z.object({ + comments: z.array(commentSchema), +}); + +export type CommentsTemplate = z.infer; + +export const commentsSchema = z.object({ + bugs: z.map(z.number(), commentsTemplateSchema), + comments: z.map(z.number(), commentSchema), +}); + +export type Comments = z.infer; + +export const createCommentContentSchema = z.object({ + comment: z.string(), + is_private: z.boolean(), +}); + +export type CreateCommentContent = z.infer; + +export const createdCommentSchema = z.object({ + id: z.number(), +}); + +export type CreatedComment = z.infer; + +export const createBugContentSchema = z.object({ + product: z.string(), + component: z.string(), + summary: z.string(), + version: z.string(), + description: z.string(), + op_sys: z.string(), + platform: z.string(), + priority: z.string(), + severity: z.string(), + alias: z.array(z.string()).optional(), + assigned_to: z.string().optional(), + cc: z.array(z.string()).optional(), + comment_is_private: z.boolean().optional(), + comment_tags: z.array(z.string()).optional(), + groups: z.array(z.string()).optional(), + keywords: z.array(z.string()).optional(), + qa_contact: z.string().optional(), + status: z.string().optional(), + resolution: z.string().optional(), + target_milestone: z.string().optional(), + flags: z.array(setFlagSchema).optional(), +}); + +export type CreateBugContent = z.infer; + +export const createdBugSchema = z.object({ + id: z.number(), +}); + +export type CreatedBug = z.infer; + +function updateListSchema(itemSchema: T) { + return z.object({ + add: z.array(itemSchema).optional(), + remove: z.array(itemSchema).optional(), + }); } -export const LoginResponseSpec: ObjectSpec = { - id: int, - token: string, -}; - -export interface Version { - version: string; -} - -export const VersionSpec: ObjectSpec = { - version: string, -}; - -export interface User { - id: int; - name: string; - real_name: string; -} - -export const UserSpec: ObjectSpec = { - id: int, - name: string, - real_name: string, -}; - -export interface SetFlag { - status: string; - name?: string; - type_id?: int; - requestee?: string; -} - -export const SetFlagSpec: ObjectSpec = { - status: string, - name: optional(string), - type_id: optional(int), - requestee: optional(string), -}; - -export interface UpdateFlag extends SetFlag { - id?: int; - new?: boolean; -} - -export const UpdateFlagSpec: ObjectSpec = { - ...SetFlagSpec, - id: optional(int), - new: optional(boolean), -}; - -export interface Flag extends SetFlag { - id: int; - creation_date: datetime; - modification_date: datetime; - setter: string; -} - -export const FlagSpec: ObjectSpec = { - ...SetFlagSpec, - id: int, - creation_date: datetime, - modification_date: datetime, - setter: string, -}; - -export interface Bug { - alias: string | string[]; - assigned_to: string; - assigned_to_detail: User; - blocks: number[]; - cc: string[]; - cc_detail: User[]; - classification: string; - component: string | string[]; - creation_time: datetime; - creator: string; - creator_detail: User; - depends_on: number[]; - dupe_of: int | null; - flags: Flag[] | undefined; - groups: string[]; - id: int; - is_cc_accessible: boolean; - is_confirmed: boolean; - is_open: boolean; - is_creator_accessible: boolean; - keywords: string[]; - last_change_time: datetime; - op_sys: string; - platform: string; - priority: string; - product: string; - qa_contact: string; - qa_contact_detail?: User; - resolution: string; - see_also: string[] | undefined; - severity: string; - status: string; - summary: string; - target_milestone: string; - update_token?: string; - url: string; - version: string | string[]; - whiteboard: string; -} - -export const BugSpec: ObjectSpec = { - alias: nullable(maybeArray(string), []), - assigned_to: string, - assigned_to_detail: object(UserSpec), - blocks: array(int), - cc: array(string), - cc_detail: array(object(UserSpec)), - classification: string, - component: maybeArray(string), - creation_time: datetime, - creator: string, - creator_detail: object(UserSpec), - depends_on: array(int), - dupe_of: nullable(int), - flags: optional(array(object(FlagSpec))), - groups: array(string), - id: int, - is_cc_accessible: boolean, - is_confirmed: boolean, - is_open: boolean, - is_creator_accessible: boolean, - keywords: array(string), - last_change_time: datetime, - op_sys: string, - platform: string, - priority: string, - product: string, - qa_contact: string, - qa_contact_detail: optional(object(UserSpec)), - resolution: string, - see_also: optional(array(string)), - severity: string, - status: string, - summary: string, - target_milestone: string, - update_token: optional(string), - url: string, - version: maybeArray(string), - whiteboard: string, -}; - -export interface Change { - field_name: string; - removed: string; - added: string; - attachment_id?: int; -} - -export const ChangeSpec: ObjectSpec = { - field_name: string, - removed: string, - added: string, - attachment_id: optional(int), -}; - -export interface History { - when: datetime; - who: string; - changes: Change[]; -} - -export const HistorySpec: ObjectSpec = { - when: datetime, - who: string, - changes: array(object(ChangeSpec)), -}; - -export interface BugHistory { - id: int; - alias: string[]; - history: History[]; -} - -export const BugHistorySpec: ObjectSpec = { - id: int, - alias: array(string), - history: array(object(HistorySpec)), -}; - -export interface HistoryLookup { - bugs: BugHistory[]; -} - -export const HistoryLookupSpec: ObjectSpec = { - bugs: array(object(BugHistorySpec)), -}; - -export interface Comment { - attachment_id?: int | null; - bug_id: int; - count: int; - creation_time: datetime; - creator: string; - id: int; - is_private: boolean; - tags: string[]; - time: datetime; - text: string; -} - -export const CommentSpec: ObjectSpec = { - attachment_id: nullable(optional(int)), - bug_id: int, - count: int, - creation_time: datetime, - creator: string, - id: int, - is_private: boolean, - tags: array(string), - time: datetime, - text: string, -}; - -export interface CommentsTemplate { - comments: Comment[]; -} - -export const CommentsTemplateSpec: ObjectSpec = { - comments: array(object(CommentSpec)), -}; - -export interface Comments { - bugs: Map; - comments: Map; -} - -export const CommentsSpec: ObjectSpec = { - bugs: map(intString, object(CommentsTemplateSpec)), - comments: map(intString, object(CommentSpec)), -}; - -export interface CreateCommentContent { - comment: string; - is_private: boolean; -} - -export interface CreatedComment { - id: int; -} - -export const CreatedCommentSpec: ObjectSpec = { - id: int, -}; - -export interface CreateBugContent { - product: string; - component: string; - summary: string; - version: string; - description: string; - op_sys: string; - platform: string; - priority: string; - severity: string; - alias?: string[]; - assigned_to?: string; - cc?: string[]; - comment_is_private?: boolean; - comment_tags?: string[]; - groups?: string[]; - keywords?: string[]; - qa_contact?: string; - status?: string; - resolution?: string; - target_milestone?: string; - flags?: SetFlag[]; -} - -export interface CreatedBug { - id: int; -} - -export const CreatedBugSpec: ObjectSpec = { - id: int, -}; - -export interface UpdateList { - add?: T[]; - remove?: T[]; -} - -export type UpdateOrReplaceList = - | UpdateList - | { - set?: T[]; - }; - -export interface UpdateBugContent { - id_or_alias: int | string | string[]; - ids: (int | string)[]; - alias?: UpdateOrReplaceList; - assigned_to?: string; - blocks?: UpdateOrReplaceList; - depends_on?: UpdateOrReplaceList; - cc?: UpdateList; - is_cc_accessible?: boolean; - comment?: CreateCommentContent; - comment_is_private?: Map; - comment_tags?: string[]; - component?: string; - deadline?: datetime; - dupe_of?: int; - estimated_time?: double; - flags?: UpdateFlag[]; - groups?: UpdateList; - keywords?: UpdateOrReplaceList; - op_sys?: string; - platform?: string; - priority?: string; - product?: string; - qa_contact?: string; - is_creator_accessible?: boolean; - remaining_time?: double; - reset_assigned_to?: boolean; - reset_qa_contact?: boolean; - resolution?: string; - see_also?: UpdateList; - severity?: string; - status?: string; - summary?: string; - target_milestone?: string; - url?: string; - version?: string; - whiteboard?: string; - work_time?: double; -} - -export interface Changes { - added: string; - removed: string; -} - -const ChangesSpec: ObjectSpec = { - added: string, - removed: string, -}; - -export interface UpdatedBug { - id: int; - alias: string[]; - last_change_time: datetime; - changes: Map< - Omit, - Changes - >; -} - -export const UpdatedBugSpec: ObjectSpec = { - id: int, - alias: array(string), - last_change_time: datetime, - changes: map(string, object(ChangesSpec)), -}; - -export interface UpdatedBugTemplate { - bugs: UpdatedBug[]; -} - -export const UpdatedBugTemplateSpec: ObjectSpec = { - bugs: array(object(UpdatedBugSpec)), -}; - -export interface Attachment { - data: Buffer; - size: int; - creation_time: datetime; - last_change_time: datetime; - id: int; - bug_id: int; - file_name: string; - summary: string; - content_type: string; - is_private: boolean; - is_obsolete: boolean; - is_patch: boolean; - creator: string; - flags: Flag[]; -} - -export const AttachmentSpec: ObjectSpec = { - data: base64, - size: int, - creation_time: datetime, - last_change_time: datetime, - id: int, - bug_id: int, - file_name: string, - summary: string, - content_type: string, - is_private: boolean, - is_obsolete: boolean, - is_patch: boolean, - creator: string, - flags: array(object(FlagSpec)), -}; - -export interface Attachments { - bugs: Map; - attachments: Map; -} - -export const AttachmentsSpec: ObjectSpec = { - bugs: map(intString, array(object(AttachmentSpec))), - attachments: map(intString, object(AttachmentSpec)), -}; - -export interface CreateAttachmentContent { - ids: int[]; - data: Buffer | ArrayBuffer; - file_name: string; - summary: string; - content_type: string; - comment?: string; - is_patch?: boolean; - is_private?: boolean; - flags?: SetFlag[]; -} - -export interface CreatedAttachment { - ids: int[]; -} - -export const CreatedAttachmentSpec: ObjectSpec = { - ids: array(int), -}; - -export interface UpdateAttachmentContent { - attachment_id?: int; - ids?: int[]; - file_name?: string; - summary?: string; - comment?: string; - content_type?: string; - is_patch?: boolean; - is_private?: boolean; - is_obsolete?: boolean; - flags?: UpdateFlag[]; -} - -export interface UpdatedAttachment { - id: int; - last_change_time: datetime; - changes: Map< - Omit, - Changes - >; -} - -export const UpdatedAttachmentSpec: ObjectSpec = { - id: int, - last_change_time: datetime, - changes: map(string, object(ChangesSpec)), -}; - -export interface UpdatedAttachmentTemplate { - attachments: UpdatedAttachment[]; +function updateOrReplaceListSchema(itemSchema: T) { + return z.union([ + updateListSchema(itemSchema), + z.object({ + set: z.array(itemSchema).optional(), + }), + ]); } -export const UpdatedAttachmentTemplateSpec: ObjectSpec = - { - attachments: array(object(UpdatedAttachmentSpec)), - }; +export const updateBugContentSchema = z.object({ + id_or_alias: z.union([z.number(), z.string(), z.array(z.string())]), + ids: z.array(z.union([z.number(), z.string()])), + alias: updateOrReplaceListSchema(z.string()), + assigned_to: z.string().optional(), + blocks: updateOrReplaceListSchema(z.number()).optional(), + depends_on: updateOrReplaceListSchema(z.number()).optional(), + cc: updateListSchema(z.string()).optional(), + is_cc_accessible: z.boolean().optional(), + comment: createCommentContentSchema.optional(), + comment_is_private: z.map(z.number(), z.boolean()).optional(), + comment_tags: z.array(z.string()).optional(), + component: z.string().optional(), + deadline: z.string().datetime().optional(), + dupe_of: z.number().optional(), + estimated_time: z.number().optional(), + flags: z.array(updateFlagSchema).optional(), + groups: updateListSchema(z.string()).optional(), + keywords: updateOrReplaceListSchema(z.string()).optional(), + op_sys: z.string().optional(), + platform: z.string().optional(), + priority: z.string().optional(), + product: z.string().optional(), + qa_contact: z.string().optional(), + is_creator_accessible: z.boolean().optional(), + remaining_time: z.number().optional(), + see_also: updateListSchema(z.string()).optional(), + severity: z.string().optional(), + status: z.string().optional(), + summary: z.string().optional(), + target_milestone: z.string().optional(), + url: z.string().optional(), + version: z.string().optional(), + whiteboard: z.string().optional(), + work_time: z.number().optional(), +}); + +export type UpdateBugContent = z.infer; + +export const changesSchema = z.object({ + added: z.string(), + removed: z.string(), +}); + +export type Changes = z.infer; + +export const updatedBugSchema = z.object({ + id: z.number(), + alias: z.array(z.string()), + last_change_time: z.string().datetime(), + changes: z.map(z.string(), changesSchema), +}); + +export type UpdatedBug = z.infer; + +export const updatedBugTemplateSchema = z.object({ + bugs: z.array(updatedBugSchema), +}); + +export type UpdatedBugTemplate = z.infer; + +export const attachmentSchema = z.object({ + // TODO: it should probably return a Buffer instead of a string + data: z.string().refine(Base64.isValid), + size: z.number(), + creation_time: z.string().datetime(), + last_change_time: z.string().datetime(), + id: z.number(), + bug_id: z.number(), + file_name: z.string(), + summary: z.string(), + content_type: z.string(), + is_private: z.boolean(), + is_obsolete: z.boolean(), + is_patch: z.boolean(), + creator: z.string(), + flags: z.array(flagSchema), +}); + +export type Attachment = z.infer; + +export const attachmentsSchema = z.object({ + bugs: z.map(z.number(), z.array(attachmentSchema)), + attachments: z.map(z.number(), attachmentSchema), +}); + +export type Attachments = z.infer; + +export const createAttachmentContentSchema = z.object({ + ids: z.array(z.number()), + // TODO: Buffer | ArrayBuffer + data: z.union([z.any(), z.string()]), + file_name: z.string(), + summary: z.string(), + content_type: z.string(), + comment: z.string().optional(), + is_patch: z.boolean().optional(), + is_private: z.boolean().optional(), + flags: z.array(setFlagSchema).optional(), +}); + +export type CreateAttachmentContent = z.infer< + typeof createAttachmentContentSchema +>; + +export const createdAttachmentSchema = z.object({ + ids: z.array(z.number()), +}); + +export type CreatedAttachment = z.infer; + +export const updateAttachmentContentSchema = z.object({ + attachment_id: z.number().optional(), + ids: z.array(z.number()).optional(), + file_name: z.string().optional(), + summary: z.string().optional(), + comment: z.string().optional(), + content_type: z.string().optional(), + is_patch: z.boolean().optional(), + is_private: z.boolean().optional(), + is_obsolete: z.boolean().optional(), + flags: z.array(updateFlagSchema).optional(), +}); + +export type UpdateAttachmentContent = z.infer< + typeof updateAttachmentContentSchema +>; + +export const updatedAttachmentSchema = z.object({ + id: z.number(), + last_change_time: z.string().datetime(), + changes: z.map(z.string(), changesSchema), +}); + +export type UpdatedAttachment = z.infer; + +export const updatedAttachmentTemplateSchema = z.object({ + attachments: z.array(updatedAttachmentSchema), +}); + +export type UpdatedAttachmentTemplate = z.infer; diff --git a/test/link.test.ts b/test/link.test.ts index fdf8667..67d2066 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -1,10 +1,10 @@ import { URL } from 'url'; +import { z } from 'zod'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { PublicLink, ApiKeyLink, PasswordLink } from '../src/link'; -import { int, object, string } from '../src/validators'; const server = setupServer(); @@ -29,9 +29,9 @@ test('PublicLink', async () => { http.get('http://bugzilla.test.org/test/rest/foo', responseHandler), ); - let testSpec = object({ - foo: string, - length: int, + let testSpec = z.object({ + foo: z.string(), + length: z.number(), }); let result = await link.get('foo', testSpec); @@ -72,9 +72,9 @@ test('ApiKeyLink', async () => { http.get('http://bugzilla.test.org/test/rest/foo', responseHandler), ); - let testSpec = object({ - foo: string, - length: int, + let testSpec = z.object({ + foo: z.string(), + length: z.number(), }); let result = await link.get('foo', testSpec); @@ -131,9 +131,9 @@ test('PasswordLink', async () => { http.get('http://bugzilla.test.org/test/rest/foo', responseHandler), ); - let testSpec = object({ - foo: string, - length: int, + let testSpec = z.object({ + foo: z.string(), + length: z.number(), }); let result = await link.get('foo', testSpec); diff --git a/yarn.lock b/yarn.lock index b9b433e..1f6c628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1333,11 +1333,13 @@ __metadata: "@types/node": "npm:20.12.11" axios: "npm:1.6.8" jest: "npm:29.7.0" + js-base64: "npm:3.7.5" luxon: "npm:3.4.4" msw: "npm:2.3.0" prettier: "npm:3.2.5" ts-jest: "npm:29.1.2" typescript: "npm:5.4.5" + zod: "npm:3.21.4" languageName: unknown linkType: soft @@ -2685,6 +2687,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:3.7.5": + version: 3.7.5 + resolution: "js-base64@npm:3.7.5" + checksum: 10c0/641f4979fc5cf90b897e265a158dfcbef483219c76bc9a755875cb03ff73efdb1bd468a42e0343e05a3af7226f9d333c08ee4bb10f42a4c526988845ce1dcf1b + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -3973,3 +3982,10 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"zod@npm:3.21.4": + version: 3.21.4 + resolution: "zod@npm:3.21.4" + checksum: 10c0/161e8cf7aea38a99244d65da4a9477d9d966f6a533e503feaa20ff7968a9691065c38c6f1eab5cbbdc8374142fff4a05c9cacb8479803ab50ab6a6ca80e5d624 + languageName: node + linkType: hard From cc795d313dc8c19a3d028abbbe20e355103febe8 Mon Sep 17 00:00:00 2001 From: Jan Macku Date: Sat, 11 Mar 2023 21:35:55 +0100 Subject: [PATCH 2/2] wip: cast object to map --- itest/test/attachments.test.ts | 2 + src/link.ts | 68 +++++++++++++++++++--------------- src/types.ts | 34 +++++++++++++---- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/itest/test/attachments.test.ts b/itest/test/attachments.test.ts index 75ecafc..f8555dc 100644 --- a/itest/test/attachments.test.ts +++ b/itest/test/attachments.test.ts @@ -68,6 +68,8 @@ beforeAll(async () => { }); test('Get single attachment', async () => { + await expect(api.getAttachment(1)).resolves.toMatchInlineSnapshot(); + await expect(api.getAttachment(1)).resolves.toEqual({ bug_id: expect.anything(), content_type: 'image/png', diff --git a/src/link.ts b/src/link.ts index 6b4d2e7..66a85c8 100644 --- a/src/link.ts +++ b/src/link.ts @@ -1,5 +1,5 @@ import { URLSearchParams, URL } from 'url'; -import { z } from 'zod'; +import { z, ZodSchema } from 'zod'; import axios, { AxiosRequestConfig } from 'axios'; import { loginResponseSchema } from './types'; @@ -28,10 +28,10 @@ function isError(payload: unknown): payload is ApiError { return payload && typeof payload == 'object' && payload.error; } -async function performRequest( - config: AxiosRequestConfig, - schema: z.Schema, -): Promise { +async function performRequest< + TSchema extends ZodSchema, + KValues extends z.infer, +>(config: AxiosRequestConfig, schema: TSchema): Promise { try { let response = await axios.request({ ...config, @@ -67,10 +67,10 @@ export abstract class BugzillaLink { this.instance = new URL('rest/', instance); } - protected abstract request( - config: AxiosRequestConfig, - schema: z.Schema, - ): Promise; + protected abstract request< + TSchema extends ZodSchema, + KValues extends z.infer, + >(config: AxiosRequestConfig, schema: TSchema): Promise; protected buildURL(path: string, query?: SearchParams): URL { let url = new URL(path, this.instance); @@ -80,11 +80,11 @@ export abstract class BugzillaLink { return url; } - public async get( + public async get>( path: string, - schema: z.Schema, + schema: TSchema, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), @@ -93,12 +93,16 @@ export abstract class BugzillaLink { ); } - public async post( + public async post< + R, + TSchema extends ZodSchema, + KValues extends z.infer, + >( path: string, - schema: z.Schema, + schema: TSchema, content: R, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), @@ -112,12 +116,16 @@ export abstract class BugzillaLink { ); } - public async put( + public async put< + R, + TSchema extends ZodSchema, + KValues extends z.infer, + >( path: string, - schema: z.Schema, + schema: TSchema, content: R, searchParams?: SearchParams, - ): Promise { + ): Promise { return this.request( { url: this.buildURL(path, searchParams).toString(), @@ -133,10 +141,10 @@ export abstract class BugzillaLink { } export class PublicLink extends BugzillaLink { - protected async request( - config: AxiosRequestConfig, - schema: z.Schema, - ): Promise { + protected async request< + TSchema extends ZodSchema, + KValues extends z.infer, + >(config: AxiosRequestConfig, schema: TSchema): Promise { return performRequest(config, schema); } } @@ -152,10 +160,10 @@ export class ApiKeyLink extends BugzillaLink { super(instance); } - protected async request( - config: AxiosRequestConfig, - schema: z.Schema, - ): Promise { + protected async request< + TSchema extends ZodSchema, + KValues extends z.infer, + >(config: AxiosRequestConfig, schema: TSchema): Promise { return performRequest( { ...config, @@ -201,10 +209,10 @@ export class PasswordLink extends BugzillaLink { return loginInfo.token; } - protected async request( - config: AxiosRequestConfig, - schema: z.Schema, - ): Promise { + protected async request< + TSchema extends ZodSchema, + KValues extends z.infer, + >(config: AxiosRequestConfig, schema: TSchema): Promise { if (!this.token) { this.token = await this.login(); } diff --git a/src/types.ts b/src/types.ts index c03e0aa..51b58b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,9 +144,29 @@ export const commentsTemplateSchema = z.object({ export type CommentsTemplate = z.infer; +function castObjectToMap( + keyValidator: K, + valueValidator: V, +) { + return z.record(keyValidator, valueValidator).transform(record => { + let result = new Map, z.infer>(); + + for (let [k, v] of Object.entries(record)) { + // if (typeof k === 'number') { + // result.set(k, v); + // } + if (k?.toString()) { + result.set(k.toString(), v); + } + } + + return result; + }); +} + export const commentsSchema = z.object({ - bugs: z.map(z.number(), commentsTemplateSchema), - comments: z.map(z.number(), commentSchema), + bugs: castObjectToMap(z.number(), commentsTemplateSchema), + comments: castObjectToMap(z.number(), commentSchema), }); export type Comments = z.infer; @@ -222,7 +242,7 @@ export const updateBugContentSchema = z.object({ cc: updateListSchema(z.string()).optional(), is_cc_accessible: z.boolean().optional(), comment: createCommentContentSchema.optional(), - comment_is_private: z.map(z.number(), z.boolean()).optional(), + comment_is_private: castObjectToMap(z.number(), z.boolean()).optional(), comment_tags: z.array(z.string()).optional(), component: z.string().optional(), deadline: z.string().datetime().optional(), @@ -262,7 +282,7 @@ export const updatedBugSchema = z.object({ id: z.number(), alias: z.array(z.string()), last_change_time: z.string().datetime(), - changes: z.map(z.string(), changesSchema), + changes: castObjectToMap(z.string(), changesSchema), }); export type UpdatedBug = z.infer; @@ -294,8 +314,8 @@ export const attachmentSchema = z.object({ export type Attachment = z.infer; export const attachmentsSchema = z.object({ - bugs: z.map(z.number(), z.array(attachmentSchema)), - attachments: z.map(z.number(), attachmentSchema), + bugs: castObjectToMap(z.number(), z.array(attachmentSchema)), + attachments: castObjectToMap(z.number(), attachmentSchema), }); export type Attachments = z.infer; @@ -343,7 +363,7 @@ export type UpdateAttachmentContent = z.infer< export const updatedAttachmentSchema = z.object({ id: z.number(), last_change_time: z.string().datetime(), - changes: z.map(z.string(), changesSchema), + changes: castObjectToMap(z.string(), changesSchema), }); export type UpdatedAttachment = z.infer;