Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 14 additions & 132 deletions bin/utils/config/load-config.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,23 @@
import path from 'node:path';

import { z } from 'zod';

import { selectActors } from '../../actor-filtering.js';
import { isPathWithinScope } from '../../path-utils.js';
import type { ActorConfig } from '../../types.js';
import { safeReadJsonObjectFile } from '../json-file.js';
import { parseConfigFile } from './parser.js';
import type { ResolvedActorConfig } from './structures/base.js';

export const CONFIG_FILE_NAME = 'apify-test-tools.config.json';

// Reading the config happens in four stages, each one swappable on its own:
// Reading the config happens in three stages, each one swappable on its own:
//
// 1. file -> plain object (safeReadJsonObjectFile)
// 2. plain object -> ActorConfigFile (parseConfigFile — the schema, swap point for new flavours)
// 3. ActorConfigFile -> ResolvedActorConfig[] (resolveRawConfig — one normalized shape for everyone)
// 4. ResolvedActorConfig -> ActorConfig (loadActorConfig — merges in .actor/actor.json)
//
// Stages 1-3 are the "what did the user write" half and stage 4 the "what does the repo look like"
// half. A differently shaped config file only has to reach stage 3's output to work with the rest
// of the tool.

// #region schema (soon to be moved)

const ACTOR_ENTRY_SCHEMA = z.object({
folder: z.string(),
// "owner/name", with neither half empty — deliberately permissive about what characters those
// halves may contain, the platform is the authority on that.
actorFullName: z.string().regex(/^[^/]+\/[^/]+$/),
tokenEnvVar: z.string(),
overrideActorContext: z.array(z.string()).optional(),
});

const CONFIG_FILE_SCHEMA = z.object({
actors: z.array(ACTOR_ENTRY_SCHEMA),
});

export type ActorConfigFile = z.infer<typeof CONFIG_FILE_SCHEMA>;

/**
* The single shape stage 4 understands: an actor entry whose paths have been normalized, with no
* trace left of which config flavour produced it. Whatever replaces or extends
* {@link parseConfigFile} and {@link resolveRawConfig} only has to produce this.
*/
export interface ResolvedActorConfig {
actorFullName: string;
folder: string;
tokenEnvVar: string;
overrideActorContext?: string[];
}

// #endregion
// 2. plain object -> ResolvedActorConfig[] (parseConfigFile — picks a strategy, validates
// and normalizes; see ./parser.ts)
// 3. ResolvedActorConfig -> ActorConfig (loadActorConfig — merges in .actor/actor.json)

// #region utils

// Strips a trailing slash so config-declared paths ("actors/shopify/" vs "actors/shopify") compare equal.
const stripTrailingSlash = (pathValue: string): string => pathValue.replace(/\/+$/, '');

const findOverlappingContextPaths = (contextPaths: string[]): [string, string] | undefined => {
for (let i = 0; i < contextPaths.length; i++) {
for (let j = i + 1; j < contextPaths.length; j++) {
Expand All @@ -70,41 +32,9 @@ const findOverlappingContextPaths = (contextPaths: string[]): [string, string] |
return undefined;
};

type ConfigParsingIssue = z.ZodError<ActorConfigFile>['issues'][number];

// Restates a schema violation in the wording this config file has always used, so error output
// stays recognizable now that the hand-rolled checks are gone.
const describeIssue = (issue: ConfigParsingIssue, rawActors: unknown[]): string => {
const [, index, field] = issue.path;

if (typeof index !== 'number') {
return `Config file "${CONFIG_FILE_NAME}" must have an "actors" array at the top level.`;
}

const folder = (rawActors[index] as { folder?: unknown } | undefined)?.folder;

switch (field) {
case 'folder':
return (
`Invalid "folder" for actor entry at index ${index} in "${CONFIG_FILE_NAME}". ` +
`Must be a string (use "." for a single-actor repo).`
);
case 'actorFullName':
return (
`Invalid "actorFullName" for folder "${folder}" in "${CONFIG_FILE_NAME}". ` +
`Must be in "owner/name" format (e.g. "apify/web-scraper").`
);
case 'tokenEnvVar':
return `Invalid "tokenEnvVar" for folder "${folder}" in "${CONFIG_FILE_NAME}". Must be a string.`;
case 'overrideActorContext':
return (
`Invalid "overrideActorContext" for folder "${folder}" in "${CONFIG_FILE_NAME}". ` +
`Must be an array of strings.`
);
default:
return `Invalid actor entry at index ${index} in "${CONFIG_FILE_NAME}": ${issue.message}`;
}
};
// The repo root is spelled "" internally but "." in a config file, so report it the way a reader
// would have written it.
const displayFolder = (folder: string): string => folder || '.';

// #endregion

Expand Down Expand Up @@ -133,52 +63,7 @@ const readConfigFileContents = async (): Promise<Record<string, unknown>> => {
};

/**
* Stage 2 — validates the file's shape. Every problem is reported at once rather than only the
* first one to fail.
*/
export const parseConfigFile = (contents: Record<string, unknown>): ActorConfigFile => {
const parsed = CONFIG_FILE_SCHEMA.safeParse(contents);
if (parsed.success) {
return parsed.data;
}

const rawActors = Array.isArray(contents.actors) ? contents.actors : [];
const messages = new Set(parsed.error.issues.map((issue) => describeIssue(issue, rawActors)));
throw new Error([...messages].join('\n'));
};

/**
* Stage 3 — normalizes the paths the user wrote and rejects entries that collide once normalized.
* The repo root is "" from here on, however it was spelled in the file.
*/
export const resolveRawConfig = (config: ActorConfigFile): ResolvedActorConfig[] => {
const seenFolders = new Set<string>();

return config.actors.map((entry) => {
const folder = entry.folder === '.' ? '' : stripTrailingSlash(entry.folder);

if (seenFolders.has(folder)) {
throw new Error(
`Duplicate folder "${entry.folder}" in "${CONFIG_FILE_NAME}". Each actor must have a unique folder.`,
);
}
seenFolders.add(folder);

return {
actorFullName: entry.actorFullName,
folder,
tokenEnvVar: entry.tokenEnvVar,
overrideActorContext: entry.overrideActorContext?.map(stripTrailingSlash),
};
});
};

// The repo root is spelled "" internally but "." in a config file, so report it the way a reader
// would have written it.
const displayFolder = (folder: string): string => folder || '.';

/**
* Stage 4 — resolves one normalized entry against the repo, reading the actor's `.actor/actor.json`
* Stage 3 — resolves one normalized entry against the repo, reading the actor's `.actor/actor.json`
* for its `dockerContextDir` and deciding which paths count as the actor's context.
*/
export const loadActorConfig = (entry: ResolvedActorConfig): ActorConfig => {
Expand Down Expand Up @@ -235,16 +120,13 @@ export const loadActorConfig = (entry: ResolvedActorConfig): ActorConfig => {
// #endregion

export const readConfigFile = async (selection: { actors: string[]; ignore: string[] }): Promise<ActorConfig[]> => {
const rawConfig = await readConfigFileContents();

// replace this when enabling different file structures
// some kind of strategy pattern seems appropriate here
const resolvedConfigs = resolveRawConfig(parseConfigFile(rawConfig));
const rawFileContents = await readConfigFileContents();
const parsedConfigs = parseConfigFile(rawFileContents);

const actorConfigs: ActorConfig[] = [];
for (const resolved of resolvedConfigs) {
for (const parsed of parsedConfigs) {
// Sequential on purpose: the first actor with a problem should be the one reported.
actorConfigs.push(loadActorConfig(resolved));
actorConfigs.push(loadActorConfig(parsed));
}

return selectActors(selection, actorConfigs);
Expand Down
88 changes: 88 additions & 0 deletions bin/utils/config/parser.ts
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);

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

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, legacy is 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.


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;
}
61 changes: 61 additions & 0 deletions bin/utils/config/structures/base.ts
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));
},
};
}
20 changes: 20 additions & 0 deletions bin/utils/config/structures/legacy.ts
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);
Loading
Loading