-
Notifications
You must be signed in to change notification settings - Fork 2
feat(config/modes): lay groundwork for multiple configuration structures #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3f998b8
feat(config/modes): lay groundwork for multiple configuration structures
JuanGalilea 5bf924e
remove redundant tests and test closer to functionality
JuanGalilea 71a9a4f
regex as requested by luigi
JuanGalilea 82c91b7
easier mode handling
JuanGalilea File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import z from 'zod'; | ||
|
|
||
| import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig, type StrategyParser } from './structures/base.js'; | ||
| import { LEGACY_PARSER } from './structures/legacy.js'; | ||
|
|
||
| const CONFIG_FILE_STRATEGIES: { | ||
| // instead of Record, we do this to ensure that the modes match | ||
| [key in CONFIG_FILE_STRATEGY]: StrategyParser & { mode: key }; | ||
| } = { | ||
| [CONFIG_FILE_STRATEGY.LEGACY]: LEGACY_PARSER, | ||
| } as const; | ||
|
|
||
| const ModeSelectionSchema = z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY); | ||
|
|
||
| function selectStrategy(mode: unknown): StrategyParser { | ||
| const parsed = ModeSelectionSchema.safeParse(mode); | ||
| if (!parsed.success) { | ||
| throw new Error(z.prettifyError(parsed.error)); | ||
| } | ||
|
|
||
| return CONFIG_FILE_STRATEGIES[parsed.data]; | ||
| } | ||
|
|
||
| // Strips a trailing slash so config-declared paths ("actors/shopify/" vs "actors/shopify") compare equal. | ||
| const stripTrailingSlash = (pathValue: string): string => pathValue.replace(/\/+$/, ''); | ||
|
|
||
| // The repo root may be written as ".", "./", "/" or ""; everything downstream spells it "". | ||
| const normalizeFolder = (folder: string): string => { | ||
| const stripped = stripTrailingSlash(folder); | ||
| return stripped === '.' ? '' : stripped; | ||
| }; | ||
|
|
||
| /** | ||
| * Normalizes the paths the user wrote and checks the cross-entry invariants a per-entry schema | ||
| * cannot: that no two actors claim the same folder once normalized, nor the same actor on the | ||
| * platform. Collects every violation before throwing, so one run reports all of them rather than | ||
| * only the first. | ||
| * | ||
| * @returns the actors it vouched for, with their paths normalized. | ||
| * @throws Error listing every problem found. | ||
| */ | ||
| function verifyConfiguration(body: ResolvedActorConfig[]): ResolvedActorConfig[] { | ||
| const seenFolders = new Set<string>(); | ||
| const seenActorFullNames = new Set<string>(); | ||
| const errors: string[] = []; | ||
|
|
||
| const normalized = body.map((entry) => ({ | ||
| ...entry, | ||
| folder: normalizeFolder(entry.folder), | ||
| overrideActorContext: entry.overrideActorContext?.map(stripTrailingSlash), | ||
| })); | ||
|
|
||
| for (const [index, entry] of normalized.entries()) { | ||
| // TODO: drop folder uniqueness (this requires several changes on other places) | ||
| if (seenFolders.has(entry.folder)) { | ||
| errors.push(`Duplicate folder "${body[index].folder}". Each actor must have a unique folder.`); | ||
| } else { | ||
| seenFolders.add(entry.folder); | ||
| } | ||
|
|
||
| if (seenActorFullNames.has(entry.actorFullName)) { | ||
| errors.push(`Duplicate actor "${entry.actorFullName}". Each entry must point at a different actor.`); | ||
| } else { | ||
| seenActorFullNames.add(entry.actorFullName); | ||
| } | ||
| } | ||
|
|
||
| if (errors.length > 0) { | ||
| throw new Error(errors.join('\n')); | ||
| } | ||
|
|
||
| return normalized; | ||
| } | ||
|
|
||
| // eslint-disable-next-line no-underscore-dangle | ||
| export const _privates = { | ||
| selectStrategy, | ||
| verifyConfiguration, | ||
| }; | ||
|
|
||
| export function parseConfigFile(body: Record<string, unknown>): ResolvedActorConfig[] { | ||
| const { mode, ...rest } = body; | ||
| const strategy = selectStrategy(mode); | ||
|
|
||
| const resolved = strategy.parse(rest); | ||
| const validated = verifyConfiguration(resolved); | ||
| return validated; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { prettifyError, type ZodType } from 'zod'; | ||
|
|
||
| import { ACTOR_NAME, USERNAME } from '@apify/consts'; | ||
|
|
||
| function stripRegexAnchor(regex: string): string { | ||
| return regex.replace(/^\^/, '').replace(/\$$/, ''); | ||
| } | ||
|
|
||
| export const ACTOR_FULL_NAME_REGEX = new RegExp( | ||
| `^${stripRegexAnchor(USERNAME.REGEX.source)}/${stripRegexAnchor(ACTOR_NAME.REGEX.source)}$`, | ||
| USERNAME.REGEX.flags + ACTOR_NAME.REGEX.flags, | ||
| ); | ||
|
|
||
| export interface ResolvedActorConfig { | ||
| actorFullName: string; | ||
| folder: string; | ||
| tokenEnvVar: string; | ||
| overrideActorContext?: string[]; | ||
| } | ||
|
|
||
| export enum CONFIG_FILE_STRATEGY { | ||
| LEGACY = 'legacy', | ||
| } | ||
|
|
||
| export type StrategyParser = { | ||
| mode: CONFIG_FILE_STRATEGY; | ||
| parse: (body: Record<string, unknown>) => ResolvedActorConfig[]; | ||
| }; | ||
|
|
||
| export function defineStrategy<T extends Record<string, unknown>, Mode extends CONFIG_FILE_STRATEGY>( | ||
| // mode is transparent so it can be validated with & { mode: Mode } | ||
| mode: Mode, | ||
| schema: ZodType<T>, | ||
| resolve: (body: T) => ResolvedActorConfig[], | ||
| ) { | ||
| return { | ||
| mode, | ||
| /** | ||
| * | ||
| * @param body raw config file to be read | ||
| * @returns ResolvedActorConfig[] if the config file is valid, otherwise throws an error with a description of the issues. | ||
| * @throws Error if the config file is invalid. | ||
| * @example | ||
| * ```ts | ||
| * const strategy = defineStrategy(CONFIG_FILE_STRATEGY.LEGACY, schema, resolve); | ||
| * const resolvedActors = strategy.parse(configFileContents); | ||
| * ``` | ||
| * | ||
| * This function is used to parse the config file according to the strategy defined by the user. | ||
| * It uses the schema defined in the strategy to validate the config file and resolve it into a list of ResolvedActorConfig. | ||
| * If the config file is invalid, it throws an error with a description of the issues. | ||
| */ | ||
| parse: (body: Record<string, unknown>) => { | ||
| const parsed = schema.safeParse(body); | ||
| if (parsed.success) { | ||
| return resolve(parsed.data); | ||
| } | ||
| throw new Error(prettifyError(parsed.error)); | ||
| }, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import z from 'zod'; | ||
|
|
||
| import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy } from './base.js'; | ||
|
|
||
| const schema = z.object({ | ||
| actors: z | ||
| .array( | ||
| z.object({ | ||
| folder: z.string(), | ||
| actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), | ||
| tokenEnvVar: z.string(), | ||
| overrideActorContext: z.array(z.string()).optional(), | ||
| }), | ||
| ) | ||
| .min(1), | ||
| }); | ||
|
|
||
| type LegacyConfig = z.infer<typeof schema>; | ||
|
|
||
| export const LEGACY_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.LEGACY, schema, (body: LegacyConfig) => body.actors); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does it pick if the file satisfies multiple parsers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
they are an
enum, so it can only match one. You explicitly state the resolution method and if not,legacyis the default (which is the current method we use). That way this is backwards compatible and users can pick different methods according to what they think is more comfortable.