0
Fork 0
mirror of https://github.com/penpot/penpot-export.git synced 2025-03-12 07:21:20 -05:00

refactor(core): validate user config in a more expressive way

zod unions are exclusive, so the UserConfig type couldn't be kept as a union either for having at least a colors, typographies or pages output.

This check has been moved to a runtime zod refine custom validation.
This commit is contained in:
Roberto Redradix 2023-09-04 15:02:11 +02:00
parent bf6f6a723a
commit 83ba6f93d5
6 changed files with 88 additions and 150 deletions

View file

@ -15,7 +15,8 @@
}, },
"dependencies": { "dependencies": {
"node-fetch": "2", "node-fetch": "2",
"prettier": "^3.0.2" "prettier": "^3.0.2",
"zod": "^3.22.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.5.0", "@types/node": "^20.5.0",

View file

@ -8,9 +8,10 @@ function normalizePenpotExportUserFileConfig(
): FileConfig { ): FileConfig {
return { return {
fileId: userConfig.fileId, fileId: userConfig.fileId,
colors: 'colors' in userConfig ? userConfig.colors : [], colors: userConfig.colors !== undefined ? userConfig.colors : [],
typographies: 'typographies' in userConfig ? userConfig.typographies : [], typographies:
pages: 'pages' in userConfig ? userConfig.pages : [], userConfig.typographies !== undefined ? userConfig.typographies : [],
pages: userConfig.pages !== undefined ? userConfig.pages : [],
} }
} }

View file

@ -1,143 +1,78 @@
import { UserFileConfig, UserConfig } from '../types' import { z } from 'zod'
import { PagesConfig, ColorsConfig, TypographiesConfig } from './types' import { UserConfig } from '../types'
class FileIdConfigError extends Error { const accessTokenSchema = z.string({
constructor() { required_error: '.accessToken is required',
super(`Missing or invalid .fileId in penpot export config.`) invalid_type_error: '.accessToken must be a string',
} })
} const instanceSchema = z
.string({
required_error: '.instance is required',
invalid_type_error: '.instance must be a string',
})
.url({
message: '.instance must be a valid URL',
})
const fileIdSchema = z
.string({
required_error: '.fileId is required',
invalid_type_error: '.fileId must be a string',
})
.uuid({
message: '.fileId must be a valid UUID',
})
const pageIdSchema = z
.string({
required_error: '.pageId is required',
invalid_type_error: '.pageId must be a string',
})
.uuid({
message: '.pageId must be a valid UUID',
})
const outputSchema = z.string({
required_error: '.output is required',
invalid_type_error: '.output must be a string',
})
class AssetListConfigError extends Error { const userColorsConfigSchema = z.object({
constructor(assetType: 'colors' | 'typographies' | 'pages') { output: outputSchema,
super(`Invalid .${assetType} list in penpot export config.`) })
} const userTypographiesConfigSchema = z.object({
} output: outputSchema,
})
const userPagesConfigSchema = z.object({
output: outputSchema,
pageId: pageIdSchema,
})
class OutputPathConfigError extends Error { const userFileConfigSchema = z
constructor(index: number) { .object({
super(`Missing or invalid .path in penpot export config at index ${index}.`) fileId: fileIdSchema,
} colors: z.optional(z.array(userColorsConfigSchema)),
} typographies: z.optional(z.array(userTypographiesConfigSchema)),
pages: z.optional(z.array(userPagesConfigSchema)),
class PageIdConfigError extends Error { })
constructor(index: number) { .refine(
super( (object) => {
`Missing or invalid .pageId in penpot export config at index ${index}.`, if ('colors' in object) return true
if ('typographies' in object) return true
if ('pages' in object) return true
return false
},
{
message:
'Each file in .files is required to have at least one of .colors, .typographies or .pages properties',
},
) )
}
}
function validateUserColorsConfig( const userConfigSchema = z.object({
colorsConfig: object, accessToken: accessTokenSchema,
index: number, instance: z.optional(instanceSchema),
): colorsConfig is ColorsConfig { files: z
if (!('output' in colorsConfig) || typeof colorsConfig.output !== 'string') .array(userFileConfigSchema)
throw new OutputPathConfigError(index) .nonempty({ message: '.files is required to have at least one item' }),
})
return true export const validateUserConfig = (userConfig: object): UserConfig =>
} userConfigSchema.parse(userConfig)
function validateUserTypographiesConfig(
typographiesConfig: object,
index: number,
): typographiesConfig is TypographiesConfig {
if (
!('output' in typographiesConfig) ||
typeof typographiesConfig.output !== 'string'
)
throw new OutputPathConfigError(index)
return true
}
function validateUserPagesConfig(
pagesConfig: object,
index: number,
): pagesConfig is PagesConfig {
if (!('output' in pagesConfig) || typeof pagesConfig.output !== 'string')
throw new OutputPathConfigError(index)
if (!('pageId' in pagesConfig) || typeof pagesConfig.pageId !== 'string')
throw new PageIdConfigError(index)
return true
}
function validateUserFileConfig(
userConfig: object,
): userConfig is UserFileConfig {
if (!('fileId' in userConfig) || typeof userConfig.fileId !== 'string')
throw new FileIdConfigError()
if ('colors' in userConfig) {
if (!Array.isArray(userConfig.colors))
throw new AssetListConfigError('colors')
userConfig.colors.every(validateUserColorsConfig)
}
if ('typographies' in userConfig) {
if (!Array.isArray(userConfig.typographies))
throw new AssetListConfigError('typographies')
userConfig.typographies.every(validateUserTypographiesConfig)
}
if ('pages' in userConfig) {
if (!Array.isArray(userConfig.pages))
throw new AssetListConfigError('pages')
userConfig.pages.every(validateUserPagesConfig)
}
return true
}
class AccessTokenConfigError extends Error {
constructor() {
super('Missing or invalid .accessToken in penpot export config.')
}
}
class InvalidInstanceUrlConfigError extends Error {
constructor() {
super('Invalid .instance URL in penpot export config.')
}
}
class FilesConfigError extends Error {
constructor() {
super('Missing or invalid .files in penpot export config.')
}
}
export function validateUserConfig(
userConfig: object,
): userConfig is UserConfig {
if (
!('accessToken' in userConfig) ||
typeof userConfig.accessToken !== 'string'
)
throw new AccessTokenConfigError()
if ('instance' in userConfig && userConfig.instance !== undefined) {
if (typeof userConfig.instance !== 'string')
throw new InvalidInstanceUrlConfigError()
try {
new URL(userConfig.instance)
} catch (error) {
if (error instanceof TypeError) throw new InvalidInstanceUrlConfigError()
throw error
}
}
if (
!('files' in userConfig) ||
!Array.isArray(userConfig.files) ||
userConfig.files.length === 0
)
throw new FilesConfigError()
userConfig.files.every(validateUserFileConfig)
return true
}

View file

@ -11,12 +11,9 @@ export default async function penpotExport(
userConfig: object, userConfig: object,
rootProjectPath: string, rootProjectPath: string,
) { ) {
if (!validateUserConfig(userConfig)) const parsedUserConfig = validateUserConfig(userConfig)
throw new Error(
'Error validating user config. This is probably an error in penpot-export code.',
)
const config = normalizePenpotExportUserConfig(userConfig) const config = normalizePenpotExportUserConfig(parsedUserConfig)
const penpot = new Penpot({ const penpot = new Penpot({
baseUrl: config.instance, baseUrl: config.instance,
accessToken: config.accessToken, accessToken: config.accessToken,

View file

@ -11,11 +11,10 @@ import type {
export type UserFileConfig = { export type UserFileConfig = {
fileId: string fileId: string
} & ( colors?: ColorsConfig[]
| { colors: ColorsConfig[] } typographies?: TypographiesConfig[]
| { typographies: TypographiesConfig[] } pages?: PagesConfig[]
| { pages: PagesConfig[] } }
)
export interface UserConfig { export interface UserConfig {
instance?: string instance?: string

View file

@ -315,3 +315,8 @@ yn@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
zod@^3.22.2:
version "3.22.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==